Skip to content

Architecture

The code is a pub workspace: the packages under packages/ — all but two for the product, the other two for the agent layer (§4.3) — and the application itself at the root (lib/: main.dart, app.dart, consts.dart, the dashboard and Settings). A package imports only what its pubspec.yaml declares, and only through the other package’s barrel (lib/<name>.dart); nothing under a package’s lib/src/ is imported from outside it. Each package carries a README.md (what it is for and what it must never do) and, where it has tests of its own, a test/. In this document a file is named by its package and basename — filmopen_format/stems.dart stands for packages/filmopen_format/lib/src/stems.dart; the application’s own files keep their lib/ paths.

The packages, read from the bottom up; every arrow points down, and a package imports only what its pubspec.yaml declares, through the other package’s barrel. The page of each package — its purpose, what it uses of each dependency, who depends on it, and every public class, function and member with the first sentence of its doc comment — is under packages/, generated from the code by scripts/package_digest.py and regenerated with every change to a package.

the application (lib/main.dart, lib/app.dart, the dashboard, Settings)
│
agent layer filmopen_mcp ─ filmopen_automation (dev dependencies of the app)
│
pages filmopen_browser ─── filmopen_compare ─── filmopen_forms ─── filmopen_tree (the tree also imports filmopen_sharing)
│
features filmopen_account filmopen_keys filmopen_device filmopen_relay filmopen_dictation filmopen_recorder
filmopen_drive filmopen_sharing filmopen_jobs_ui filmopen_media_ui filmopen_insertion_ui filmopen_plugin_host filmopen_plugins_ui filmopen_plugin_dev
filmopen_render_ui filmopen_support filmopen_text_ui filmopen_assistant_ui filmopen_picker filmopen_prompt_ui filmopen_timeline_ui
│
shared UI filmopen_ui (theme, Breakpoints, shared widgets; no state)
│
state filmopen_state (Riverpod; the seams; patchEntity; the import's apply)
│
pure logic filmopen_insertion ─ filmopen_jobs ─ filmopen_render filmopen_sync filmopen_cloud filmopen_media filmopen_machine filmopen_plugins filmopen_js filmopen_exchange filmopen_l10n filmopen_platform
│
files filmopen_store ─ filmopen_test_support (exchange imports the store)
│
the format filmopen_format (pure Dart; imports nothing)

| Layer | Packages | What lives there | Flutter? | | The format | filmopen_format | the file format: stems, documents, the index, resolution and picks, epochs, diagnostics codes, the manifest, canonical JSON, MediaSlot, the project key | no | | Files | filmopen_store, filmopen_test_support | the folder source, the loader, the writer and its refusals, the creator, Guards, the log; the fixtures every suite shares | no | | Pure logic | filmopen_jobs, filmopen_insertion, filmopen_render, filmopen_sync, filmopen_cloud, filmopen_media, filmopen_plugins, filmopen_js, filmopen_comps, filmopen_exchange, filmopen_machine | jobs and their journal; bringing media into a film; the one queue of renders, which needs no film (§6.15); comparing a film’s files with a sharing target; the FilmOpen Cloud client and sync target; where a film’s bytes live (MediaStore); what a plug-in is; QuickJS bound; standalone comparison JSON, its bundle and its publishing (§6.17); exchanging a film (§6.18), with the store’s dry validation and guarded writer; this computer’s facts with pure parsers and a bounded platform reader, depending on the store for AppLog alone | no | | Timeline engine | filmopen_engine, filmopen_engine_mlt, filmopen_engine_mpv, filmopen_engine_android, filmopen_engine_bench | the Android engine on Media3 (a Flutter plug-in imported by the app’s root alone, §19.16); the libmpv engine, the second, chosen at build time (its adapter, its compilers to an EDL and to FFmpeg’s graph, the vendored media_kit texture, a stub without dart:ffi, §19.16); the MLT engine for Linux and Windows (a Flutter plug-in: the C shim over dart:ffi, its texture, the compiler to MLT’s XML, export; a stub that refuses everything where there is no dart:ffi), imported by the app’s root and the bench (§19.16); the engine interface, the timeline an engine plays, the frame arithmetic, the presentation record, FakeEngine and LoggingEngine (pure Dart, depending on the store for AppLog alone, §19); the bench, a Flutter app for Linux and Windows that drives any engine through the fixture scene and reports every measure (§19.11), imported by no package | the engine: no; the MLT and libmpv engines and the bench: yes | | Scene timeline | filmopen_timeline | a scene’s timeline derived from its file and its project, never stored whole: the three rules — the entry in use, a block’s output duration, a block’s picture —, the build on frames and its compile to an EngineTimeline, the OTIO export with its manifest and report (pure Dart: format, media, engine, and the store for AppLog, §19.12–§19.14) | no | | Platform and words | filmopen_platform, filmopen_l10n | the operating system and the bundle, in threes (x.dart, x_io.dart, x_stub.dart); every user-facing sentence, one ARB per language, codes turned into sentences | yes | | Screenplay import | filmopen_fdx | pure Dart XML-to-project conversion; format/store dependencies, conditional file reader with isolate work, no Flutter or network | no | | State | filmopen_state | Riverpod providers, Selection, the tree model, PageAccess, the seams agents and plug-ins attach to (OpenDrafts, FieldRegistry, AutomationHost, PluginRuntime, ImportProposals), patchEntity, the import’s apply over filmopen_exchange (ControllerImportWriter), manifest updates and application sync ownership through filmopen_sync, the timeline engine and each scene’s timeline (timelineEngineProvider, sceneTimelineProvider over filmopen_engine and filmopen_timeline) | yes | | Shared UI | filmopen_ui | the theme, Breakpoints, the widgets more than one page shares | yes | | Features | filmopen_account, filmopen_keys, filmopen_device, filmopen_relay, filmopen_dictation, filmopen_recorder, filmopen_drive, filmopen_sharing, filmopen_jobs_ui, filmopen_media_ui, filmopen_insertion_ui, filmopen_plugin_host, filmopen_plugins_ui, filmopen_plugin_dev, filmopen_render_ui, filmopen_support, filmopen_text_ui, filmopen_assistant_ui, filmopen_picker, filmopen_prompt_ui, filmopen_timeline_ui | one product feature each, behind its own barrel: the account, provider keys, the device identity, Remote control, dictation, the issue recorder, Google sign-in and the Drive sync target, the Sharing card and its account and target selection, job rows and Activity, how media looks, the media place every page shows and adds media with, the plug-in runtime, Settings → Plugins, the developer page, the render dialog’s contract and Render, a recording that can be sent (the flash, what the person says, the description, the zip, the ticket and the upload), the prose editor with shown text, canonical source and an editable-text target adapter, the AI assistant’s field button, dialog, panel and Settings text-model picker, prompt-builder catalogs and deferred pictures, the Prompt card, a scene’s timeline dock — its tracks, its player and transport, its one edit and its exports, on whatever engine timelineEngineProvider makes (§19.15) | yes | | Pages | filmopen_tree, filmopen_forms, filmopen_compare, filmopen_browser | the tree pane; the form in two modes, the autosave and the write actions; two columns and copy-left; the pages and their tabs | yes | | Agent layer | filmopen_automation, filmopen_mcp | the driver extension and the data channel; the MCP server. No product package imports them. | yes |

The exact dependency of each package on each other, with sizes and test counts, is the generated index of the packages. filmopen_prompt_ui, the Prompt card (§9.3), imports filmopen_format, filmopen_store (for the log), filmopen_state, filmopen_insertion, filmopen_insertion_ui (for the Workflow tab’s Generate box), filmopen_media, filmopen_media_ui, filmopen_text_ui, filmopen_ui and filmopen_l10n, holds the prompt’s @ source (PromptMentionCandidates, §9.8) and what the card and the tab’s Render share (prompt_generate.dart) beside the card, and sits below its three consumers, filmopen_forms, filmopen_render_ui and filmopen_browser, importing none. filmopen_media depends only on filmopen_format at runtime; its tests load fixtures through filmopen_test_support. filmopen_plugin_host depends on filmopen_exchange for the plug-ins’ exchange doors (§6.11), whose proposals it holds through the state’s ImportProposals.

filmopen_cloud depends on filmopen_format, filmopen_sync, filmopen_store, http and crypto, with no Flutter or account dependency. filmopen_sharing imports its public barrel and supplies the account-token callback and application domain when resolving a Cloud manifest; the root reaches the target through that resolver. The client and target share private operation and connection state inside one Dart library; no provider-specific rule enters the sync engine.

Forbidden, and impossible once the pubspecs say so: a lower package importing a higher one; any product package other than the application importing filmopen_sharing or filmopen_render_ui — save filmopen_tree, whose new-project dialog places the sharing’s cloud and director blocks (sharing imports no page); the format, the jobs, the insertion, the media, the state or a page reaching filmopen_render, directly or through any package between; filmopen_format, filmopen_store, filmopen_test_support, filmopen_media, filmopen_jobs, filmopen_insertion, filmopen_render, filmopen_exchange or filmopen_sync importing Flutter or a plugin; filmopen_state importing a widget package (the tree model names its icons as a TreeIcon and the tree view chooses the glyph); a product package importing the agent’s own packages (filmopen_automation, filmopen_mcp); dart:io anywhere but the _io half of a file that is in threes — the store, the platform, the machine facts package, the user interface, the account, the recorder, the support package (filmopen_support/report_files_io, filmopen_support/report_client_io), and the media packages (filmopen_insertion/local_file_io, filmopen_sync/folder_target_io, filmopen_sync/local_bytes_io, filmopen_sync/ledger_store_io, filmopen_sync/sync_parts_io, filmopen_media_ui/player/media_player_io, filmopen_drive/drive_sign_in_io, filmopen_sharing/full_path_io, and no other half of any of the threes — the isolate half takes dart:isolate, the pickers take file_selector and the drop, filmopen_insertion_ui/file_drop_io, takes desktop_drop) — and never on a path the web build reaches — and two files that never reach the web build, filmopen_test_support/sample_on_disk.dart (a test’s way to the sample) and filmopen_recorder/bin/compare_shots.dart (a command-line tool of replay’s, never bundled into any build). Platform code comes in threes — x.dart (a conditional export), x_io.dart, x_stub.dart — so the same call compiles on the web: unavailable platform operations answer “not available”, while ledger and parts stores use their memory implementations. filmopen_l10n/labels.dart is the single bridge from the format’s codes to human text.

filmopen_forms depends on the internal filmopen_text_ui package for its shared prose fields and script text. The Prompt card’s package supplies project-aware mention candidates (filmopen_prompt_ui/prompt_mentions.dart) to the forms and to the render dialog, which imports filmopen_text_ui for its prompt’s box; the reusable editor retains no project or Riverpod dependency. Each rendered prose consumer owns an identity-released canonical-value registration in the state package, whose reader retains only its controller. The recorder samples that committed source without importing a widget package.

What the split buys: an agent sent to Compare loads that package, the barrels of filmopen_forms and filmopen_format and Compare’s page under packages/ — not the writer, the account or the tree; two agents in two packages touch different files and different pubspecs, and their one shared file is the ARB pair.

project folder ──┐ two ProjectSources (folder / assets / memory), each its own sandbox
shared library ──┘ listPaths(), readText(), locate(), diskPath()
▼
ProjectWriter ◄── (save / fork / official / pick: writeText, deleteFile on the project's source, then a re-read)
▲
ProjectLoader ──► FilmProject (manifest, library; index: entities by stem (with origin),
│ groups by type+tag, epochs, versions, official pointers, commentary, batches,
│ takes (with media location), other files, warnings, project candidates,
│ selected project)
▼
projectProvider (AsyncNotifier) ◄── libraryProvider (FutureProvider: the library folder, seeded on first run)
│
├─► scriptOutlineProvider ──► the one walk of the script (§5.12), read by the tree and the pages
├─► treeModelProvider ──► TreeBuilder(project, l10n, viewer, outline) ──► TreeModel(roots, index by selection key)
│ │
▼ ▼
browserProvider (BrowserState: selection, highlighted node, toggled rows) ◄── TreePane clicks / DetailPane links
│
▼
DetailPane: switch on Selection → ProjectOverview | ProjectChooser | CategoryView | EntityDetail | BatchDetail | OtherFilesView

Everything is derived: change the project, the viewer handle or the language, and the tree and pane rebuild from the index. Nothing is cached beyond Riverpod’s own memoisation. A write is a file write followed by refresh: the new index replaces the old, and because the browser resets its navigation only when the source changes (projectSourceKeyProvider), the page being edited stays where it is. main builds the ProviderContainer itself and hands it to the AutomationHost in use (§7.1) before runApp.

Clipboard asks. Every text field asks the platform whether the clipboard holds text — once when it is built, and again on each rebuild while it may paste — and on Linux each ask is a native clipboard request; GTK 3 on Wayland answers a burst of overlapping requests quadratically and reads no input from the compositor until the answers drain, so a page of hundreds of editable fields froze the app while it was typed into. The binding’s messenger (FilmOpenBinding, lib/binding.dart, made by main) therefore sends through ClipboardAsks (filmopen_state), on every platform: a Clipboard.hasStrings asked while one is outstanding shares that one’s reply, its bytes as they came, an error envelope or a failure included; the slot is released before any caller sees the reply, so the next ask is a native one; a Clipboard.setData makes the outstanding ask stale, and an ask after the write starts its own. A change made outside the app while an ask is pending may give the asks that joined it the earlier answer, which the next ask corrects — a toolbar opening, a rebuild, a resume. Every other message passes as it is, in order; nothing is cached, timed or limited. The agent build keeps the driver’s binding, whose test messenger takes a ClipboardAsks of its own as its outgoing handler for flutter/platform, forwarding through the messenger’s delegate (§4.3). The log says each shared ask: clipboard.hasStrings.start {op, via, caller, arguments, reason, stalePending} when one goes out, .joined {op, caller, callers, ms} for each that shares it, so that the log shows how many wait behind an ask that has not answered, .stale {op, reason: setData, callers, ms}, and .done {op, callers, stale, ms, answer} or .failed {op, callers, ms, error, …} with a platform error’s code, message and details decoded for the log alone.

4.3 The agent layer — packages/filmopen_automation, packages/filmopen_mcp, test_driver/agent_main.dart

Section titled “4.3 The agent layer — packages/filmopen_automation, packages/filmopen_mcp, test_driver/agent_main.dart”

The agent layer lets a coding agent, or later a support agent, drive a running FilmOpen through named operations instead of the desktop: the data channel’s protocol, the host that answers it, and the MCP server’s tools, proved on Windows by the acceptance walk and the release check, and on a headless Linux box under Xvfb. The layer is two packages in the workspace beside the product’s, and one entrypoint outside lib/.

  • packages/filmopen_automation is the in-app half. installAgentExtension installs Flutter’s driver extension with handleAgentCommand (protocol.dart) as its data channel. A message is the literal ping or a JSON object naming an op and its arguments: state, navigate (a selection as {"kind", "type", "tag", "epoch", "stem"}), open_project, set_tab, set_field, tap (by identifier, or by label through a host’s SemanticsTaps), scroll (a host with SemanticsScrolls reveals an identifier through vertical semantics actions without activating it), tap_at (a diagnostic, never a step of a recorded sequence), click (a mouse click on an identified control with held keys), drag (a mouse drag from one identified row to another), pick_files (the answer of the next file dialog, held once), find, semantics, screenshot, log_tail, set_viewer, set_locale, set_theme, record_start, record_stop, record_state (answered by a host with Recording, the issue recorder’s doors, §6.8: refused recording when one already runs, busy while one is starting or stopping, idle on record_stop when none runs, failed recorder when it cannot start or its folder cannot be completed — a host without Recording answers not_available), quit (answered by a host with AppExit, which writes the open drafts, puts back the viewer, language, theme, development mode, dictation’s microphone, Hold to record and speech service the session started with, waits for a recording still starting and stops one that is running, and exits once the answer has left — or refuses with refused / drafts while a draft cannot be written, and refused / viewer while the username the session started with cannot be put back or kept in the preferences). The answer is {"ok": true, "result": …} or {"ok": false, "code": …, "detail": …}: the code one of unknown_op, bad_args, not_available, not_found, refused, intercepted, timeout, failed, and the detail a code, a stem or an identifier — never text a person wrote. The MCP tool selects its operation; an extra argument cannot replace it. Typed text and a tapped label are never repeated, an unexpected failure travels as its type (its message and stack are in agent.failed), and log_tail carries each event as the log file writes it, an error’s message and stack and the account’s events’ data included. Arguments are checked before the host hears of them; what no host is asked (a ping, a message that is not an op, an unknown op) is answered at once. The ops a host answers run one at a time, none starting before the one ahead of it has ended, and each is answered within 30 seconds of arriving — timeout when it cannot be, with queued (it never runs) or running (it may still land, and the log says when). Every op goes to AutomationHost.current: the product’s no-op host answers not_available, and the agent build installs DriverAutomationHost (driver_host.dart). That host:
  • reads the providers for state: the project, the browser, the rail’s page, the settings, FieldRegistry.stems, the view’s size and pixel ratio;
  • navigates through navIndexProvider and browserProvider.navigate, refusing a target the project does not hold;
  • opens a project, and sets the viewer, the language and the theme, through the project’s controller and Settings’;
  • types through FieldRegistry;
  • finds, taps and describes the page through the semantics tree it asks the binding to keep (filmopen_state/semantics_walk.dart, which the issue recorder shares). Its taps — by identifier, by label, and a tab — go through the platform dispatcher’s semantics-action callback rather than straight on the semantics owner, the door a screen reader’s own request takes, so a recording started with record_start hears an agent’s taps too (§6.8, §7.1). A tap lands on the node a click would reach, and a disabled control is refused. A tab is tapped through the tab bar’s own gate: it answers refused with the code of its tooltip, or intercepted by Compare’s fork question.

The scroll op takes an exact id, page-reading direction (down or up), an optional within semantics region (page.content by default; page.tree for the tree; page.body and page.header for an entity’s independently scrolling tab body and version header), and maxScrolls from 1 to 40, default 20. Stable page and tree containers keep their children distinct. The host shows an existing target, including a disabled control, without activation; otherwise it chooses the region’s one visible vertical scrollable and performs Flutter’s finger-up action to move down the page, or finger-down to move up. It re-reads semantics after each action and keeps the selected scrollable. An ambiguous region is refused / ambiguous_scrollable; an end, no progress or exhausted count is not_found with the target identifier. A 20-second deadline is timeout / scroll, with a settle of at most 500 ms between actions. The answer is {scrolled: {id, direction, steps}}. A narrower identified region can disambiguate multiple lists. The walk and numbered runner reveal missing controls down, then up on not_found, at ordinary window heights; a missing control fails instead of being skipped below the fold.

The semantics data gives rects in logical pixels. It withholds a text field’s value outside the project’s own identifiers (field:, script: — a scene’s blocks on its Edit tab —, epochs., new-project., new-entity., settings.handle, tree.filter, dictation., and plugin:, a plug-in’s settings on this computer, and plugin-dev.plugin and plugin-dev.function, the developer page’s two choices, with plugin-dev.result-file and plugin-dev.log-file, the app’s own two paths, which the page exists to hand an agent — not its args box, which is where a developer would paste a key to try a request, and not settings.plugin-dev-folder, which is a person’s own text), and gives every other text as it is, as state does the handle, the project’s name and its path. screenshot paints AutomationHost.windowKey’s boundary at the view’s pixel ratio — the whole view, every route, dialog and menu included — falling back to rootKey only where no window boundary is built. log_tail reads RingLogSink: the log’s last 500 lines as the log file writes them, an error’s message and stack included. An op waits for its frame and the animations it started, up to 5 seconds, or until no semantics update has been sent for 200 ms while an animation runs on — an indeterminate spinner repeats forever and changes nothing in the tree; set_theme waits without that exit, since the colours that follow the theme fade on with the tree unchanged — never for a file; each wait is logged, agent.settle with its phase, cap, quiet interval and whether semantics are there, and agent.settled with how it ended (idle, stable or cap), its ms and, when stable, the quiet age. The package is a dev dependency of the app, imports filmopen_recorder, filmopen_state, filmopen_store and filmopen_format and nothing above them, and no product package ever imports it.

  • test_driver/agent_main.dart is the only place the extension is installed. Before any binding exists it installs DriverAutomationHost and then the extension, and it runs the ordinary main, which attaches the application’s container to the installed host. It then writes agent.json into the log folder (§6.4): the VM service URL (dart:developer’s Service.getInfo), the process id and the start time. The file is written beside itself and renamed over it, at once and again if the URL changes within the first minute — a write that fails is tried again on the next tick — so a server can find a running agent build without being told; quit removes it while it still names the process. The URL carries the service’s token, so the log names only the file. lib/main.dart does not know the file exists, and a release build has no VM service, no handler and no listener. This is Flutter’s own shape for the driver extension (a target outside lib/), so there is no dart-define and no branch in main.
  • The agent build’s modes (test_driver/agent_modes.dart): two for proofs against the platforms, then an import proposal, the assistant fixture, live speech and the account (below), each asked for by the environment of the process that starts the build — flutter run, scripts/agent-run.sh or launch, which pass it through — and none reached by a product build. Its keys live in memory (MemoryCredentialStore): the device’s store is shared by every FilmOpen build on a machine, so an agent build does not write there — unless the persistent store is asked for (FILMOPEN_AGENT_PERSISTENT_STORE 1), which puts the device’s key alone in the device’s own store (DeviceKeyOnlyStore over createDeviceCredentialStore) — though nothing writes it there unless the account below is asked for, since the registrar skips an ephemeral store —, every other name — the provider keys among them — in memory; that store is ephemeral, as the provider keys in it are, so Provider keys keeps its keys’ states in memory there too, never in the preferences, which on a real home are the product build’s (a record written there can name a key the product’s store holds under another record, whose renders are then refused); on Linux the launch’s own session bus gets a keyring of its own, unlocked with an empty password under the launch’s XDG_DATA_HOME, where gnome-keyring is installed, and without it or without a bus of its own the device’s key cannot be kept (CredentialStoreUnavailable). In every mode, then, Provider keys keeps each key’s record in memory, and the plug-in host reads whether a key is saved from those records rather than from the preferences (KeyRecordSource.reader, answered by AgentModes.keyRecordIn), since the preferences hold none of them and, on Windows, may be the machine’s own and hold records of keys this build does not have; nothing is written. Only an isolated fixture’s own preferences may seed a record’s account and expiry, and its decision — agent.keyRecord.fixture or .skipped {name, record, reason, ms} — is logged when it differs from the last one for that name, not at every read (.failed every time). Live keys (FILMOPEN_AGENT_LIVE_KEYS: 1 for every platform with a key file, else the platforms’ ids separated by commas): the platforms check keys (HttpKeyVerifier), and the app reads each named platform’s key from the keys folder — FILMOPEN_KEYS, else filmopen-keys beside the repository, <platform>.key — and saves it through Provider keys’ own door (ProviderKeysController.save) on each card the tab draws (keyCardsOf), now and as plug-ins and the catalogue change, once a session per card, so a key refused is not sent again — unless that card’s plug-in’s settings on this computer have changed since its try stored nothing, since a platform such as Salad checks a key against the organization and project typed there; the value is shown only as its last four characters, as a typed key is, and logged or written nowhere but the store, and the log says agent.liveKey with the card and the result alone, and, at a later notification, agent.liveKey.retry or agent.liveKey.held with the card, the platform, the plug-in’s tag and the reason (settingsChanged, settingsUnchanged, trying while its try runs, and settingsChangedDuringTry where the settings changed while the platform checked the key, which is then tried with them); settings the runtime is still reading are no change (settingsUnavailable), and where a try saw none the first ready ones are what it is compared with (settingsFirstReady). A platform needing two values is typed by a person. The stand-ins (FILMOPEN_AGENT_STANDINS, the path of a JSON object of origins to base URLs on the loopback, {"https://fal.run": "http://127.0.0.1:8190"}): the plug-in host’s connection (createPluginRuntime(client:)) and the key check’s are a StandinClient, which sends a request to an origin the file names to its stand-in — method, headers, body and query as they were, after every check of the host was made on the address the plug-in asked for — and refuses any other before anything is sent, the log naming each by its host alone (agent.standinSent, agent.standinRefused); a redirect is answered as it came and never followed, so no hop leaves the routes; the platforms check keys there too. A stand-ins file that cannot be read, that names an origin with anything beside it, or that gives a base URL off the loopback or with a query, a fragment or a user refuses the launch (standinsUnread, standinsInvalid), as does a live-keys value that is not 1 or platform ids (liveKeysInvalid) or a keys folder that is not there (liveKeysNoFolder): the build ends before anything is sent. With neither, the stub checks keys by how a fake value ends (§6.7). The agent build opens no link outside the app (WithheldLinks): a plug-in’s Docs, a key’s guide or a sign-in page is answered as not opened, the page saying so as it does wherever no browser opens, and the log names the link’s host alone (agent.linkWithheld), never its path or query, which can carry a sign-in’s code — so that no run starts the machine’s browser with the person’s own profile. An import proposal (FILMOPEN_AGENT_IMPORT_PROPOSAL, an import document’s path) is proposed to the film open, writing nothing. The assistant fixture (FILMOPEN_AGENT_ASSISTANT_FIXTURE 1) installs the host’s fixture assistant and a fixture text provider into a library of the run’s own, selects them with an invented key the stub verifies, refuses every request of the host and the key check, and has the composition install no voice service; it refuses live keys and the stand-ins, and starts only in the launcher’s private folders on Linux or the sandbox home on Windows (assistantFixtureNotIsolated). The Windows sandbox (FILMOPEN_SYNOPSIS_VOICE_SANDBOX 1 with a drive-absolute FILMOPEN_SYNOPSIS_HOME, else sandboxHomeInvalid) moves every app folder into that home and starts on fresh preferences there, with the fixture or without it. Live speech (FILMOPEN_AGENT_LIVE_SPEECH 1) leaves the device’s own microphone and transcriber to main in place of the fakes, the usage log still in memory; it refuses the stand-ins and the assistant fixture (liveSpeechCombined). The account (FILMOPEN_AGENT_ACCOUNT 1) signs the build in to the website’s development project as the owner’s test account on dev, as the repository’s CLAUDE.md §5 allows, the one identity it knows, so that a proof can spend that account’s dev credit: the build leaves the account’s connection alone (FilmOpenAccountAuth.withhold is not called), while Drive, links, the browser and the sign-in hint stay withheld. Before any credential is read it refuses an auth project or a site that is not dev’s (accountNotDev), a start without the persistent store (accountNeedsStore), beside the stand-ins or the fixture (accountCombined), without the password’s file in the keys folder (accountNoFile), and a launch carrying a sign-in’s callback (accountCallback), each written on stderr with the values decided on and its elapsed ms, after the check’s own start line (agent.account.check, which says its acceptance too). Once the app has made the account’s model it reads the stored session’s identity from the device’s own store, under the product’s key, before the account’s client starts — since starting it persists and may remove that session: another account’s (or one naming nobody) is refused (accountOtherSession) with nothing written, deleted, asked, published or registered; the test account’s is restored; the password is read from its file only where no session came back (else accountNoFile, accountUnreadable; a store that cannot be read is accountStoreUnavailable), handed to the account’s own password door and dropped — never logged, typed or passed on a command line; a refusal is accountSignInRefused, its reason in the account’s own line, and no user within the bound accountSignInFailed. The device’s registrar is given the device’s own store in place of the ephemeral one, keeping its metadata, its name in the preferences and what it tells on registering, while Provider keys stay in memory; the registration and the balance are then waited for, 30 s at most and never asked again (accountNotReady with the stage that did not come). The log says agent.account.start, agent.account.dev, agent.account.signIn, agent.account.stored, agent.account.keyFile (the path and its outcome, never its text), agent.account {outcome: restored | password | refused, code, ms}, agent.account.registrar, agent.account.ready.start and agent.account.ready {device, balance, ms} or agent.account.notReady, beside the account’s and the device’s own lines; agent.home says account: the test account.

A Linux agent started directly must receive private XDG data, state and cache roots for that run. It must not inherit the ordinary application’s folders. On Linux and Windows, durable renders first use the fixed filmopen-agent-render child of a nonempty private FILMOPEN_AGENT_DATA root. Without that root, Linux preserves its XDG_DATA_HOME child and Windows preserves FilmOpen/filmopen-agent-render beneath LOCALAPPDATA; no explicit or platform root refuses durable startup. Relaunch with the same root recovers the journal, and deleting an owned root removes it. Neither agent render manager reads the ordinary application’s supplied jobs folder or sweeps its staged media.

  • packages/filmopen_mcp is the MCP server: a Dart command-line program on the Dart team’s dart_mcp that a client spawns (claude mcp add filmopen -- dart run …/bin/filmopen_mcp.dart serve). Nothing in the app imports it.
  • Tools. It offers one tool per op of the data channel: state, navigate, open_project, set_tab, set_field, tap, tap_at, click, drag, pick_files, find, semantics, page_shot (the op screenshot), log_tail, set_viewer, set_locale, set_theme, record_start, record_stop, record_state, quit and ping. Beside them are screenshot (the whole view as Flutter rendered it, every route and dialog included, but not the native window frame or a native dialog), wait_idle, enter_text (the driver’s own text entry into the box that has the focus, for the dialogs’ boxes, Settings’ handle and a provider key’s box, which are not a FieldRegistry form, and into a key’s box only a visibly fake value, since the text passes through the client’s transcript; bad_args / text without a text, and nothing tells whether a box was focused), launch and stop.
  • Answers. An op tool sends its op with the call’s arguments, which the app checks. It answers the result as JSON text, or a picture as image content, and the app’s refusal as an error result carrying its code. A failure on the link answers no_connection with the error’s type as detail and its message as error, whole, a service URL it quotes included. The server logs each tool call on stderr, its own channel, a JSON line each: mcp.started {tool, arguments, queuedMs} (what enter_text types whole), mcp.done {tool, ok, answer?, ms} (a refusal’s answer with it), and an unexpected failure as mcp.failed {tool, step, type, error, stack}, the step being url, link, action, launch, launched, stop or quit, with decision and reason where the call goes on another way (a launched app that cannot answer its quit is ended with q); every line keeps a service URL whole. The link prints nothing of its own, and the lines of a failed launch travel as they were printed. screenshot writes its PNG to a path only as a new .png inside the server’s working directory, or over one it wrote itself (bad_args / path otherwise): a client must not overwrite a file through a picture. A picture shows what the screen shows.
  • The link. The server keeps one link (AppLink, over Flutter’s driver), reuses it and drops it after a failure; an owned session reconnects only to its own URL. It runs its tool calls one at a time, so two never race to open the link, or to launch and stop the app. Every wait is bounded and nothing is left open, since FlutterDriver.connect never gives up and cannot be stopped: the link opens the VM service’s WebSocket itself, with an HTTP client it closes by force when the time runs out (10 seconds), and builds vm_service’s client on it; finds the isolate that carries the driver extension (ext.flutter.driver, asked again until the same 10 seconds have passed, an isolate paused at its start resumed as the driver resumes it); closes the connection when either step fails; and only then hands it to the driver (VMServiceFlutterDriver.connectedTo), whose requests, window shots and idle waits each get 45 seconds. A request past its time drops the link. When the client goes, the calls still queued are answered shutting_down without running, an app the server launched is stopped, and the call under way is given 45 seconds. Nothing answering is ServiceUnreachable, and a service without the extension is NotAnAgentBuild (not_agent_build), even where the time runs out while one of its isolates is asked about; neither names a URL.
  • Which app. Once launch owns an app, the server retains that session’s process, service URL and home. Operations never discover a replacement, and an explicit foreign vmServiceUrl is refused with bad_args / vmServiceUrl. Instances. On Linux each launched app keeps every file it writes under its own home — its preferences, the library, the plug-ins’ data and staged media, the logs and agent.json, the jobs’ and the renders’ journals — its keys and its usage log in memory, and a session bus of its own (dbus-run-session where the machine has it, else an address nobody listens on), never the desktop’s, whose keyring the account and Drive would otherwise store their sessions in. Two launched at once share only the repository, whose build each launch makes again — so they start one after the other — and the keys folder, which they only read; launch answers the home as the instance’s identity (instance.home, made absolute), a refusal after the start included, and a one-shot command reaches the app whose home it names (XDG_STATE_HOME) and none of the launched ones with no home named. On Windows each launch runs in the app’s sandbox under its home (made in the repository’s build/sandbox-home/; FILMOPEN_SYNOPSIS_HOME for a one-shot command), an agent build refusing a Windows start without it (homeNotPrivate) unless FILMOPEN_AGENT_REAL_HOME is 1; a second launch hands itself to the first, so one app runs at a time there. When the owned app ends, operations answer no_app / ended; stop acknowledges or ends only that owned app and retains the ended marker (by: ended if it had already exited). A new launch is allowed after stop. A server that has never launched an app, and the one-shot commands, keep their existing explicit-URL and agent.json discovery behavior. Discovery accepts only an http service URL on the loopback whose service answers; the announcement’s PID is informational and does not replace the owned session’s identity.
  • Launch and stop. launch runs flutter run -d windows|linux|<an android device's serial> -t test_driver/agent_main.dart from the repository. On Linux it is setsid xvfb-run -a -s "-screen 0 <size>x24", inside dbus-run-session where the machine has it, and there with gnome-keyring-daemon --unlock --components=secrets started first where that is installed, its control directory under the launch’s home. On android the device argument is required and stands where the platform’s name would — a machine may have a phone and an emulator attached at once — while size and home are refused rather than ignored, there being no screen to make and no folders to give: state.view reports what the device has. A launch on a Linux host is put in a process group of its own whatever the target, so the last-resort kill takes the dart tool and its adb children with it. Android writes no agent.json a server could read (it is inside the app’s sandbox), so a launched android app is known from flutter run‘s output alone and a command in another shell is given --vm-service-url. size is <width>x<height> within 320–4096 (1280x720 when omitted): the Xvfb screen and the window on Linux, FILMOPEN_WINDOW on both runners — the view’s size on both, since the Windows runner adds the frame the origin’s monitor gives a window (AdjustWindowRectExForDpi) and leaves the window 1280×720 outer, as it always was, when the variable is unset. The MCP server’s and command line’s home on Linux and Windows is a private folder whose native share child supplies FILMOPEN_AGENT_DATA; omitted, a fresh folder under the temp directory is made and removed on stop, while a named home is preserved. On Linux its /share, /state and /cache suffixes also become the app’s XDG directories, using Linux separators regardless of the host, so a launch never reads the person’s ~/.local/share/filmopen. It reads the service line flutter run prints, connects, waits for an idle frame, applies project, theme and locale, and answers the state; a refusal after the app started carries launched: true, and stop still ends it. A launched app that ends by itself retains its session identity while its link is closed and its temporary resources are cleaned; subsequent operations return no_app / ended until another authorized launch. A flutter run that ends or runs out of time before its app answers is launch_failed with exited or timeout, its exit code and its last 40 lines, as printed; an unexpected failure answers its type as detail and its message as error, whole. stop ends an app the server launched with the app’s own quit, over a link opened for it when none is, so the open drafts are written and the settings put back, lets it end by itself (up to 30 seconds), and only then sends q to its flutter run (by: quit); an app that cannot answer quit, or has none, is ended with q alone (by: q); a flutter run still running 30 seconds later is ended with its whole process tree (by: kill) — on Linux kill -TERM -- -<pid> of the setsid group, then -KILL. A quit still writing a draft when its time runs out (timeout / running) is waited for, never cut short. A server that has never owned a launch may ask a discovered app to end only through the app’s own channel: quit, then a wait of up to 30 seconds for the service to stop answering. Any other refusal of the quit, a draft the app cannot write among them, is passed on, and the app stays. It never ends a process it did not start. On android the app ends its own process once quit has written the drafts, put the session’s settings back and answered: Flutter’s exitApplication is the desktop embedders’ door and does nothing there, so the activity would go to the background, the process would live on and flutter run would wait for an app that never ended. That line is test_driver/agent_main.dart’s alone — the agent layer’s own entrypoint, which no product build reaches — and it is what lets a stop from a shell the server knows nothing about end an app at all. An app the server launched stops when the client goes — asked over the open link to quit first, a few seconds at most, so that its drafts and settings are handled — even one whose flutter run has not started yet. One build at a time from one checkout: flutter run builds into the checkout’s build/, so a launch — the server’s, and the command line’s, which says on stderr that it waited — takes a lock beside the builds (build/.filmopen-agent-launch.lock, LaunchLock, naming its process) for as long as its build runs — until its own app’s service answers or the launch fails — and a second launch from the same checkout waits for it: at most the server’s timeout before that timeout begins, five minutes on the command line. A lock whose process has ended (on Linux, which says so cheaply) or older than ten minutes, which no build takes, is taken over at once; past its wait a launch takes it over all the same, and where no lock can be made or taken over at all it goes on without one once its wait is out. A client that goes while the server’s launch waits — the server’s own stop as it ends — ends the launch there (launch_failed / stopped), building nothing; a client’s stop call waits behind its launch, as every call does. The command line’s android launch takes no lock, its app announcing itself nowhere this machine reads.
  • The command line. The executable’s other commands (state, navigate, tap, set-field, enter-text, find, log-tail, page-shot, record-start, record-stop, record-state, stop, quit, and more) do the same, once each, from a shell, and launch runs flutter run in the foreground — under the same desktop home as the server’s launch (AppLauncher.desktopHome), on Linux with XDG folders of its own, named on stderr for the commands that follow (XDG_STATE_HOME), logging on stderr launch.started {platform, repository, size, home, device, realHome} at its start (launch.refused without a repository), launch.command {executable, arguments}, launch.lock {locks, waited, ms}, and ending with launch.ended {platform, exitCode, ms, homeRemoved?, error?}, ms counted from the start, a wait for another build included; a bad --size is a usage error; stop asks the build to quit through its channel and waits for it to end. launch android --device <serial> runs it on a device: the serial stands where the platform’s name would, --size and --home are usage errors there, and the commands that follow take --vm-service-url from flutter run’s own line, an android app’s agent.json being inside its sandbox. scripts/agent_walk.py runs the acceptance walk of the MCP Specification §7 through the server as a client, on either platform. The executable’s own options (--vm-service-url, --repo, --help) stand anywhere before a --, and every other word is the command’s, so tap --label and a negative number reach it. A command exits 1 when the app refuses or cannot be reached, with a sentence — an unexpected failure’s being its type and its message, whole — and its process ends as soon as it has answered. A URL that is not on the loopback is refused (exit 64). A transient message — four seconds for a confirmation, ten for one about the state of a person’s files — is drawn in every build and answered by find and in screenshot while it shows; each one-shot command starts a process of its own, which can take longer, so a message is looked for from a client that stays connected, at once after the action that shows it, as the acceptance walk does with Compare’s copy.

Rules: the app never imports the MCP library; nothing under lib/ and no product package imports filmopen_automation; the agent layer reaches the app only through the doors the UI uses — browserProvider.navigate, FieldRegistry and the forms’ own setters, the writer through WriteActions, copy_left.dart — never a second write path. The agent’s control attaches to §7.1’s seams and adds no other.

Dart’s documentation is the doc comment: /// before a declaration, its first sentence the summary, [Name] for a reference to another symbol. Every public class carries one, and so does every method whose behaviour is a rule; a comment says why, not what, and cites the section a rule comes from. A package’s README.md is its narrative: what it is for, what it must never do, how it is used.

From that, one page per package is generated under packages/ by scripts/package_digest.py: the pubspec gives the dependencies, the README’s first paragraph the purpose, the barrel the public surface, the doc comments the meaning, and the importing files which names of each dependency the package actually uses. A reader — a person or an agent — loads the page of the package they work in and the pages of its dependencies instead of their code. The pages are regenerated with every change to a package’s public declarations or doc comments and committed with it; they are never edited by hand.

filmopen_assistant_ui supplies the reusable field button, dialog, panel and Settings text-model picker. The forms and browser compose its public widgets. Its injectable service and typed session live in filmopen_state; root composition installs the plugin host’s adapter wherever the plug-in host runs, and the web keeps the inert service, whose controls say that assistance is unavailable. The UI never imports the host, forms, account or plugin configuration packages. The session sends each typed text turn’s envelope through the bound host backend, and dictation remains a separate text-input feature.

On Windows, Android and Linux the normal composition installs a separate AssistantVoiceService, with no compile-time switch; on the web, macOS and iOS it installs none. The field gesture captures the draft, source and assistant choice; Start alone admits a native voice session. The host prepares the voice session’s task instructions and quoted context itself, and applies the conversation’s result with its own finalizeConversation; no plug-in function takes part. The native host owns the saved key, fixed direct provider endpoint, microphone, output device, session clock and Usage. Its interface is identified by the host’s private API identifier filmopen-assistant-sandbox-2026-10-04; neither the canonical plug-in API nor a provider package defines its transport.

The Grok WebSocket voice session sends PCM16 little-endian mono audio at 24 kHz and uses provider voice activity detection. Its socket (connectRealtime) opens the endpoint the session built, follows no redirect and is bounded at fifteen seconds, logging its opening or its failure with the error (voice.socket.open, voice.socket.failed), its close with the server’s code and reason (voice.socket.closed) and a frame that is not an event with what it held (voice.socket.refused). Playback has a device-measured cursor and a bounded queue. Interruption stops buffered output and truncates the provider’s assistant item at that cursor, including a zero-length item or one whose received audio has all played while generation continues. Late output for an interrupted item is discarded. A missing truncation acknowledgement stops the connection rather than advancing inconsistent history. Close owns pending device starts, a Mute stop and a pending socket handshake until their cleanup settles; End awaits every release, logs a failed one with its error, and then gives back the host slot and the microphone owner.

OpenAI uses one direct WebRTC call. The native host posts the offer and model to the fixed OpenAI HTTPS calls endpoint with the saved key, follows no redirect and makes no automatic retry. An ordered oai-events channel carries session configuration, quoted context, transcript events and reported usage. Native media tracks carry audio; no PCM recorder, playback cursor or second caption connection is opened. Input transcription uses the same call and provider item identities. Output-buffer events determine speaking state, and the server handles interruption and history truncation. End owns both signaling and peer cleanup.

RealtimeTranscript holds the voice session’s message snapshots, each under its provider’s item identity, separately from its transport. Cumulative input replaces earlier partial text, delta input appends, and a final input entry ignores subsequent partials. Marking input incomplete or assistant output interrupted preserves its text. The transcript keeps every message whatever their number or size.

The voice assistant uses ordinary runFilmOpen composition: normal preferences, library, logs, Usage and the native DeviceCredentialStore. Settings can read and save provider keys through the usual credential seam. Once the plug-in runtime has attached, the composition installs the assistant’s services in one call (installAssistantService): the text service wherever the plug-in host runs, and the voice service where the platform has a voice route (Windows, Android and Linux). Each outcome is logged — assistant.install.start, then assistant.install with text and voice each installed or skipped, a skip’s fixed reason (noVoicePlatform, agentFixture, noHost) and ms — and an unexpected failure is assistant.install.failed with its stage and the error’s type, and is thrown. There is no other entry point. The agent build’s Windows sandbox and its assistant fixture mode (§4.3) alone redirect application folders and the Windows preference filename, with MemoryCredentialStore and StubKeyVerifier, and the composition installs no voice service in the fixture mode (agentFixture), so the text road is driven on the real text host.

filmopen_platform exposes WebRtcAudioPeer as a provider-neutral transport boundary. Its Windows, Android and Linux implementation owns one native peer, audio transceiver, optional ordered text channel and explicitly requested input stream, using the pinned WebRTC SDK internally. Construction and offer generation acquire no microphone. A host supplies the remote answer and enables input only after its own control channel is ready. The boundary carries media direction, safe lifecycle observations, bounded uninterpreted text frames and closed failure categories. The host interprets provider commands and owns transcript state; the peer has no account or billing behavior. The existing PCM session and playback cursor remain a separate transport implementation.

The native audio module on Windows, Android and Linux has one owner until every original acquisition and cleanup future drains. Mute fences pending capture, detaches input and releases owned tracks and streams; a new input request reacquires only after the preceding work drains. A saved microphone ID must exactly match a native WebRTC input, or be absent from both native enumeration and the existing recording source to permit default fallback. An ID still present only in the recording source is unmappable and refused. Enumeration failures never mean absence. Peer disposal precedes native closure, stale callbacks are ignored, and uncertain cleanup keeps the ownership gate closed. Other platforms expose an unavailable peer.