Skip to content

merge: upstream v0.78.0 into main-tun-tmp-fix - #3

Merged
kittinan merged 132 commits into
main-tun-tmp-fixfrom
chore/merge-upstream-v0.78.0
Sep 18, 2026
Merged

kittinan merged 132 commits into
main-tun-tmp-fixfrom
chore/merge-upstream-v0.78.0

Conversation

@kittinan

Copy link
Copy Markdown
Owner

Summary

Brings upstream ffroliva/gflow-cli v0.78.0 (origin/main, 127 commits, v0.73.2 → v0.78.0) into the fork's 27 commits. Merge-base was 7095dccf (2026-09-11).

Conflict decisions

  • Exit code clash — AvatarUnavailableError 39 → 40. Upstream released 39 as FlowAccessUnavailableError (account has no Flow entitlement); the fork had independently used 39 for the Avatar gate. Updated in code, CLI help, tests, USAGE, KNOWN_ISSUES, AGENTS.md; CHANGELOG ### Changed flags it for scripts.
  • r2v --duration keeps the fork's rule — refuse 4s/6s, allow 10s on omni-flash (measured 2026-09-12, newer than upstream's 2026-09-06 blanket "8s only").
  • client.generate_video: fork's Avatar pre-flight, then upstream's create-project-when-missing (Bundle: no-project generation on flow.google.com (createProject 401 as expired login) + MCP profile contention ffroliva/gflow-cli#864).
  • migrated_composer: upstream start+end frame support and docstrings + the fork's character-entity chip comments; submit observer keeps both the fork's migrated.submit_reply_shape log and upstream's frame-rpcid recording.
  • errors.py / KNOWN_ISSUES / USAGE: capability text merged (end frames + character references + Avatar; 3:4 image aspect).
  • CHANGELOG: fork items stay under [Unreleased], upstream releases follow unchanged.

Follow-ups the merge required

Verification

  • ruff / ruff format / pyright strict / doc links / website mirror / hygiene / PII / council memory — green
  • Full offline suite: 4703 passed (the 2 merge-induced failures above fixed and re-run)
  • Live, zero credits, copy of a lapsed profile: tests/e2e/test_auto_relogin_e2e.py — 1 passed, auth.flow_session_refresh outcome=ok twice (startup revive + reactive)
  • Not live-verified: upstream's new generation paths (end frames, project creation on flow.google.com) and the Avatar exit-40 path on this merged tree — they carry upstream's / the fork's own prior live verification only.

MCP

No new surface from the conflict resolution. Upstream's MCP changes come in as released; the exit-code move reaches MCP through EXIT_CODE_MAP.

AI4Unow and others added 30 commits September 13, 2026 01:33
…e upload

1. verification.py: fail-closed migrated-host session fallback — for accounts
   Google moved to flow.google.com the labs NextAuth session is never minted
   (labs cookies absent entirely); upgrade GOOGLE_SESSION_ONLY to AUTHENTICATED
   only when SAPISID + __Secure-OSID/OSID are present AND the email resolves
   from myaccount.google.com.

2. migrated_composer.py::_pick_frame_by_name: Flow's Frames picker now requires
   an explicit confirm ('Add to prompt') after picking — click it when the
   overlay stays up instead of timing out into UiSelectorDriftError (5/5 i2v
   failures on a migrated account).

3. migrated_composer.py::attach_start_frame: upload a run-unique COPY of the
   start frame so the picker's display-name search has exactly one match —
   repeated same-name uploads made the search bind a stale duplicate and the
   submit-body assertion fired post-click (credits spent, clip lost).

Verified live on a migrated ULTRA account: login verified via the fallback;
i2v 9:16/8s end-to-end (submit YhhmEf, polls jwpduf, result as29s, download)
with ffprobe-confirmed output. Evidence and repro details in the linked issue.
)

A missing .gflow_browser_strategy marker made the Playwright cookie reader
raise SecurityError, which escaped normalization and was flattened into
VERIFICATION_ERROR — whose guidance is 'check network connectivity', for a
file on the user's own disk. It is also the state a failed FIRST login
leaves behind, and VERIFICATION_ERROR is not GOOGLE_SESSION_ONLY, so a
migrated-host fallback keyed on the latter would never run on those
profiles. Adds a distinct PROFILE_MARKER_MISSING outcome, mirrored on the
MCP twin as 409 / retryable=false.

gflow credits raised 'aisandbox-pa authentication failed' with the
remediation 'SAPISID cookie missing, expired, or unreadable' when
labs.google answered 200 with no access_token — the normal shape for a
migrated account. aisandbox-pa had not been contacted and SAPISID was
fine, so the suggested re-login loop could never terminate. The raise now
carries an accurate hint; credits stays labs-only on migrated accounts,
tracked in ffroliva#795.

Refs ffroliva#795, ffroliva#796, ffroliva#791
…ked (ffroliva#794)

The migrated composer parked its pooled page on about:blank in a bare
`finally`, so on the FAILURE path it navigated away before
FlowApiClient._capture_incident read the page -- whose own docstring
promises to stage the bundle 'while the page is still alive'. Every
migrated video and image failure shipped tag_counts.div = 0, a white
screenshot and host_category = 'other' beside a network journal proving
the app was alive. Reported in ffroliva#792 as the driver losing its page handle
on a portrait run; it is neither portrait-specific nor a lost handle --
the reporter's own viewport (1280x720, client.py:495) proves it was the
driven page, captured one navigation too late.

The park is deferred and drained at the top of the next locked body,
before the route decision reads page.url. That placement covers the
post_with_retry path, where a retryable 5xx re-enters the transport
without passing the client's failure boundary -- attempt 2 would
otherwise resume on a still-mounted composer and risk a double submit.

Also removes a 4 s stall: _settle_if_redirecting on about:blank matches
neither short-circuit and waits out URL_SETTLE_TIMEOUT_MS for a pattern a
blank page can never take. The goto/settle ratchet carries the exemption
with its reason.

Verified live on a migrated account at zero cost by a new committed e2e
case: FAILED in 46.3 s on develop, passed in 37.8 s with the fix, with
div 0 -> 25, title 0 -> 81, host_category other -> flow_app.

Refs ffroliva#792
…unique (ffroliva#800)

Two defects on Flow's migrated flow.google.com host, reported and root-caused by
@ai4U23, who verified the fix end-to-end on the affected cohort.

1. The Frames picker no longer commits on the option click for some cohorts; it
   waits for its own "Add to prompt". gflow sat out FRAME_COMMIT_HIDDEN_S for an
   auto-close that never came and raised UiSelectorDriftError with the asset
   already picked, failing i2v before submit (no credits spent). The picker now
   gets a grace period and, if still up, its confirm is clicked -- scoped to the
   live pane and gated on is_visible(), since the method reopens the picker up to
   three times and a detached pane's button still counts.

   Anchored on button.detail-add-to-prompt-btn, measured on the Frames entry
   (scripts/dev/spike_frames_picker_confirm.py) rather than on the translated
   label the report used. The same spike killed the obvious structural shortcut:
   the popover's other visible button is header-close-btn, so "the button that is
   not an asset option" would have clicked Close.

2. Both attach paths find their upload again BY DISPLAY NAME -- the Frames picker
   searches it (i2v), the @ mention queries it (r2v) -- and Flow lists an upload
   under the name it was given. A re-run of the same file left two identical
   entries and the lookup could bind the stale one. i2v leaned on the picker's
   newest-first sort to break the tie; that is a sort assumption about someone
   else's app, and it lost. Uploads now carry a run-unique display name, handed
   over as a Playwright FilePayload so nothing is copied to disk.

   Fixed once in _unique_display_name + _upload_via_toolbar, not on the path the
   issue names: attach_references reaches the same upload leg and re-uses
   reference images across runs by design, so r2v was the likelier victim.
   Applies to video i2v, video r2v and image i2i alike.

The fake could not model any of this -- it closed the picker unconditionally on an
option click, so the commit assertion tested the fake, not Flow. It now models a
picker that stays open, one with no confirm, one that ignores its own confirm, and
a library that lists an upload under the name it was uploaded with. It also raises
the REAL Playwright TimeoutError on waits: with the stand-in, a narrowed except
silently tests nothing.

Also adds docs/MCP.md section 6 "Troubleshooting a failed tool call", routed from
docs/INDEX.md -- cohort triage, an envelope -> cause -> action table, and how to
recognise a pre-v0.73.2 blank incident bundle as no evidence at all.

Verified: full offline suite (4248 passed, 92%), ruff, pyright strict, 5 doc and
hygiene gates, SonarCloud green. Live $0 e2e on a migrated account: the happy path
passed, and a forced-grace run drove the confirm branch itself against real Flow
(confirm_clicked=False -- the picker had already closed, is_visible() correctly
skipped the click, the chip bound, which is the measurement the no-op safety claim
needed). Eleven-dimension council review; every finding addressed or answered.

Not verified, named external blocker: the confirm CLICK has never executed against
live Flow, because no picker on this cohort stays open. Covered offline across all
three outcomes and A/B-controlled; the ffroliva#792 reporter is the verifier. The r2v half
of the naming fix is offline-only -- it shares the upload leg the i2v e2e exercised
live, but its own attach was not re-run.

Refs ffroliva#792
…ffroliva#801)

The canary had been RED with an identical failure set since 2026-09-11, and every
run posted the same comment ending "Canary gates nothing. Triage at your
convenience." A standing condition -- a capability Flow took away from this
account, already understood -- looked exactly like fresh drift, which is the thing
the canary exists to catch. The set being UNCHANGED was the informative part, and
nothing reported it.

RED is not demoted. The module docstring is emphatic that labelling a real
regression "ignore me" is how the signal dies, and ffroliva#560's council review already
caught one such demotion. This only adds what a single run cannot say:

  continuity(previous, failing) -> (state, note)

a pure function beside `classify`, fingerprinting the sorted failing set
(order-insensitive -- pytest promises no stable order and a reshuffle is not new).
Same set as last time -> "Unchanged for N consecutive runs", and say that a red
nobody owns is how a canary trains red-blindness. Different set -> "The failing
set changed since the last run", the arm worth looking at today. The note REPLACES
the flat triage line rather than sitting beside it: a report saying both "at your
convenience" and "broken for four nights" is arguing with itself, and the reader
believes the softer half.

Only RED and GREEN update the streak. AUTH-EXPIRED and DEFERRED learned nothing
about Flow, so one night with Chrome open must not erase a week-old red.

State lives in tmp/canary-state.json (gitignored, disposable). Every read and
write degrades to "no continuity known" rather than taking the nightly down -- a
test caught int() raising on a hand-edited value, which would have done exactly
that.

Verified by running the real script twice, dry-run, $0:
  run 1 -> RED, first-ever fingerprint, no streak claimed (correct)
  run 2 -> RED, {"fingerprint": "7ac1eca0bf29", "runs": 2},
           "Unchanged for 2 consecutive runs."

Those runs also produced the finding the feature exists to surface: today's set is
NOT the nightly's. The four credits/aisandbox failures are identical -- the
standing condition (ffroliva#795, this account losing the ya29 Bearer around 2026-09-11) --
while the two agent-mode transport tests now pass and a different test has joined.
A stable core with flapping edges, which is exactly what the report could not say.

Gates: ruff, ruff format, pyright strict, 4 doc/hygiene checks, 4261 passed, 92%.

Refs ffroliva#559
…froliva#802)

stop re-deriving a credits auth verdict through a browser (ffroliva#802)

`gflow credits` on a migrated account told users their SAPISID cookie was broken
and to re-run `gflow auth login`. Every clause was wrong, and the advice is
harmful: on a profile with no browser-strategy marker a failed FIRST login rolls
that marker back -- the ffroliva#791 spiral.

v0.73.2 fixed the remediation for the raise site where labs mints no token. It
could not help the cohort that gets a token and has aisandbox-pa reject it,
because that message was never coming from a credits raise site at all:
services/credits.py::_fetch caught its own accurate verdict in a bare
`except Exception` and re-derived it through Chrome, failing inside the SHARED,
route-blind _run_with_aisandbox_retry. The good message was being computed and
then discarded.

The browser is no longer consulted once aisandbox-pa has ANSWERED -- it asks the
same endpoint for the same Bearer and gets the same refusal. The other site
(labs 200, no token) KEEPS its browser rescue: httpx sends labs.google cookies
only while the browser carries the full jar and bootstraps a real navigation,
which can renew a session httpx cannot. If that rescue fails too, the fast path's
diagnosis survives rather than the route-blind default. Council D1 falsified the
first commit's blanket re-raise; D14 reached the same place independently.

`credits list` now reports the remediation per profile -- it rendered only the
class title, so the multi-profile surface and gflow_get_credits(all_profiles=true)
were the one door that still could not say why.

Measured live on a migrated profile at $0: `credits user` ~7 s -> 2 s with no
browser launch; across 9 saved profiles `credits list` drops from 9 Chrome
launches to 6 (3 skip it, 4 keep their rescue, 2 fail unrelated); 9 of 9 rows now
carry a remediation where none did.

Two e2e tests, cohort-agnostic by construction, both run live on the migrated
cohort and both took the failure branch. The pre-existing happy-path e2e tests
stay RED -- that is the real capability loss (ffroliva#559), not test rot.

Nine council dimensions resolved; CodeRabbit's two findings fixed. `credits`
itself remains unavailable on the migrated host, so ffroliva#795 stays open; the class
default still reaches every non-credits aisandbox route, filed as ffroliva#803.

Refs ffroliva#795, ffroliva#791
… drift (ffroliva#804)

name the agent-only composer instead of calling it selector drift (ffroliva#804)

Google has put some accounts on a flow.google.com composer with NO classic arm: the
only prompt box is the agent panel, and aspect/model/count are Agent-settings
defaults rather than per-request controls. Every control the migrated driver reaches
for is structurally absent, so gflow waited out the 30 s readiness gate and raised
UiSelectorDriftError -- exit 23, which reads as OUR frontend bug and invites a retry
that cannot work. @Cstanish127 reported it with the DOM capture that made it
diagnosable.

It now raises FlowAgentUiError: exit 25, retryable=False, $0, pre-submit.

THE DISCRIMINATOR IS THE CHIP. The DOM is identical to ffroliva#749's recoverable agent mode
-- settings trigger present under a bare `hidden` -- which is why it read as drift.
What separates them is that a recoverable account still has a button.agent-mode-chip
to turn off and this one has none, so AGENT_MODE_CHIP_ANY exists alongside
AGENT_MODE_CHIP (pressed-only). A trigger that has LEFT the DOM stays drift: that is
a renamed selector, our bug.

MEASURED, not assumed. The fix rests on whether Playwright reports <button hidden="">
as present-but-invisible; a fake answers whatever it was written to answer, so
tests/api/transports/test_agent_only_composer.py measures it against real Chromium on
the reporter's markup. Two negative controls were then run against LIVE Flow at $0:

  healthy migrated composer (ffroliva, real project page)
    trigger visible, agent-mode-chip present and UN-PRESSED -> False
  live /about landing (denon82, ffroliva#756)
    trigger absent -> False

The first corrected a claim in this PR's own comment. An un-pressed chip is not
"never observed" -- it is the NORMAL state of a healthy migrated composer, which is
the point: a working account on this host HAS a chip, so having none is genuinely
anomalous. The state never observed is an un-pressed chip beside a HIDDEN trigger.

`retryable` moved from FlowAppError to the error base, under the condition
FlowAppError's own comment set ("move it up if, and only if, a second class needs
it"), then narrowed in documentation: six subclasses with their own __init__ reject
it loudly rather than being threaded through for a case no raise site has. A test
pins that split so the note cannot rot.

Eight council dimensions plus CodeRabbit, all resolved. D4 proved the fail-closed
branch was uncovered (migrated_composer.py 80% -> 93%); D9 found two prose
contradictions that survived a clean rebase; D15 moved the 23->25 note to ### Changed
and corrected "exit 25" to "exit-25-equivalent"; D14 de-duplicated a third copy of
the Chromium fixture; D1 found FlowApiError's legacy branch eating `retryable=`.

NAMED BLOCKER: that Flow still SERVES this DOM is unverified -- no account available
to us is in that cohort. ffroliva#799 stays open for that and for the agent-panel driver,
which is not implemented.

Refs ffroliva#799
…from host membership (ffroliva#807)

measure both Flow domains, and stop asserting capability from host membership (ffroliva#807)

Two spikes and the vocabulary change they force. No behaviour changes: the source
edits are error strings, docstrings and one env-var description.

SPIKE 1 -- labs vs flow.google.com, protocol by protocol. A sweep of all 31 prior
spike notes found ZERO mentions of gRPC, gRPC-Web, raw protobuf, WebSockets, SSE or
the negotiated HTTP version. Never ruled out; never looked for. 3 accounts x 2 entry
points x 2 runs:

  * labs.google/fx/tools/flow returns HTTP 308 PERMANENT REDIRECT, 6/6 visits --
    server-side, and a different mechanism from the client-side /about redirect.
  * The graduated app's Angular root is `aisandbox-root`, the same name as
    aisandbox-pa.googleapis.com. One product lineage, two frontends.
  * No WebSocket, SSE, gRPC or raw protobuf on Flow's hosts. The one
    application/json+protobuf per run is OneGoogle's account bar. STOMP is moot --
    it rides on a WebSocket and there is none.
  * HTTP/3 (QUIC) dominates. Never recorded, and invisible through Playwright.

SPIKE 2 -- is there a push channel during an ACTIVE generation? No. Zero WebSocket
events, zero streaming, across the full submit->completion lifecycle at zero Veo
credits. The mechanism is neither polling nor push: ogiZ0b is dispatched ONCE and
answered 19.687s / 19.841s later with the finished images in the reply. The
instrument had to be fixed mid-spike to see it -- the first version recorded response
ARRIVAL only, which cannot tell "sent late" from "held open for 20s".

THE VOCABULARY. "X is labs-only" and "on a migrated account, X fails" use host
membership to predict capability. Host membership is uniform across the surveyed
profiles where capability is not, and no account here can reach labs to test a
labs-only claim -- making it unfalsifiable, not merely unproven. Corrected wherever
it was a live claim, including two strings people actually read: credits.py told
USERS the endpoint was labs-only (aisandbox is shared), mcp/tools.py told AGENTS a
capability was labs-only (gflow has not ported it), and migrated_composer.py blamed
Flow for a model missing from our own matcher table. AGENTS.md carries the rule so
it is enforced rather than swept once.

NOT changed: "migrated host" as a NAME, the identifiers FlowHostMigratedError /
GFLOW_CLI_FLOW_HOST / migrated_composer.py, released CHANGELOG entries and dated
spike notes, and the skills that QUOTE "labs-only" as the phrase to avoid.

THREE AUDITS, and the sharpest findings were against this branch's own work: a
listener leak that corrupted the saved evidence (fixed, all evidence regenerated); a
pre-registered "unmeasured" verdict that the findings then failed to apply to their
own data; two false claims introduced while removing false claims, one contradicting
its own docstring five lines above; and an over-broad rule that could have read as
"delete the host branching". The WebSocket negative was A/B-validated by pointing the
detector at a page that does open a socket, and the one blind-spot class
(COOP: same-origin) was ruled out for both hosts.

HONEST LIMITS: the video path is unmeasured for push, and spike 2 CORRECTS its own
premise there -- images use a held response, video polls, so an absence on one is not
an absence on the other. 3 of 9 profiles were surveyed (the three with a live Flow
session). This is not "labs is gone": v0.67.0 drove a pt-locale account through the
labs route, and migrated_route's labs arm is still reached routinely for reasons
unrelated to host. Spike 2's script was committed BEFORE its data existed, which is
the ordering gap spike 1 could not prove.

Refs ffroliva#639, ffroliva#791
Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `astral-sh/setup-uv` from 10.0.1 to 10.1.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](astral-sh/setup-uv@20cfd1b...bec219d)

Updates `github/codeql-action/upload-sarif` from 4.37.9 to 4.38.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@cdf488f...b96794f)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…va#806)

Bumps the python-minor-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [platformdirs](https://github.com/tox-dev/platformdirs) | `4.11.7` | `4.11.8` |
| [mcp](https://github.com/modelcontextprotocol/python-sdk) | `2.1.1` | `2.2.0` |
| [sse-starlette](https://github.com/sysid/sse-starlette) | `3.4.10` | `3.4.11` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.6` | `0.16.7` |
| [pyright](https://github.com/RobertCraigie/pyright-python) | `1.1.411` | `1.1.414` |


Updates `platformdirs` from 4.11.7 to 4.11.8
- [Release notes](https://github.com/tox-dev/platformdirs/releases)
- [Changelog](https://github.com/tox-dev/platformdirs/blob/main/docs/changelog.rst)
- [Commits](tox-dev/platformdirs@4.11.7...4.11.8)

Updates `mcp` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases)
- [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md)
- [Commits](modelcontextprotocol/python-sdk@v2.1.1...v2.2.0)

Updates `sse-starlette` from 3.4.10 to 3.4.11
- [Release notes](https://github.com/sysid/sse-starlette/releases)
- [Commits](sysid/sse-starlette@v3.4.10...v3.4.11)

Updates `ruff` from 0.16.6 to 0.16.7
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](astral-sh/ruff@0.16.6...0.16.7)

Updates `pyright` from 1.1.411 to 1.1.414
- [Release notes](https://github.com/RobertCraigie/pyright-python/releases)
- [Commits](RobertCraigie/pyright-python@v1.1.411...v1.1.414)

---
updated-dependencies:
- dependency-name: platformdirs
  dependency-version: 4.11.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-minor-patch
- dependency-name: mcp
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-minor-patch
- dependency-name: sse-starlette
  dependency-version: 3.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-minor-patch
- dependency-name: ruff
  dependency-version: 0.16.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python-minor-patch
- dependency-name: pyright
  dependency-version: 1.1.414
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: python-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…s client timer (ffroliva#808)

Closes the last open question from the 2026-09-14 protocol spikes, and the only
one that cost credits. Spike #2 settled the image path (ogiZ0b held open ~19.7 s,
one response, no push and no poll) and then corrected its own premise: video uses
a poll loop, a different mechanism, so an absence on images was never an absence
on video. This measures video directly.

One t2v generation on ffroliva (veo_3_1_lite, x1, ~10 credits), full
submit->completion lifecycle instrumented.

THE ANSWER. It is a fixed client timer and Flow's page owns it. Seven consecutive
jwpduf inter-DISPATCH gaps span 10 ms end to end (5.002 5.009 5.011 5.005 5.008
5.001 5.004, mean 5.006, stdev 3.5 ms). The same eight polls' ARRIVAL gaps have
stdev 178 ms and spread 641 ms -- fifty times wider, pure network jitter. That
ratio is what excludes the alternative: a cadence sent in response to anything
would inherit that thing's jitter, so dispatch spacing would be at least as ragged
as arrival spacing. A clock the network cannot perturb is a local clock.

No collapse at completion (last inter-dispatch gap 5.004 == first 5.002). The job
finished during the poll dispatched at 53.554; that poll's own reply carried
status 3, and as29s went out 34 ms later -- the client reacting to the answer it
asked for. Nothing inbound between polls: the only non-batchexecute responses are
four GA4 /g/collect beacons (204 No Content) and one play.google.com/log, both
outbound telemetry, the latter sitting between two dispatches 5.011 s apart.
Nothing is held open either -- every poll answered in under 0.81 s, against
ogiZ0b's 19.7 s on the image path.

So push is now measured absent on BOTH generation paths rather than generalised
from one, and "we could subscribe instead of polling" is refuted by measurement
for video as it already was for images. The 5 s interval is a floor we read, not a
latency knob we hold. Recorded in docs/ARCHITECTURE.md and the migrated-host
memory so a future proposal meets it.

PRE-REGISTRATION. The video arm and its five readings were committed and pushed
BEFORE the run; spike #1 could not prove that ordering and had to say so. The
note applies the reading to its own data outcome by outcome -- including the arm
that fired while its consequent was refuted ("a client timer WE OWN; latency is
OURS to tune"), which is recorded as a correction rather than reworded away.

THE DETECTOR'S OWN BLIND SPOT, measured rather than argued. Every survey in this
series bound page.on(...), but a service worker's fetches are reported on the
BrowserContext, so a page-scoped zero cannot tell "no such traffic" from "traffic
on a surface I never bound". Binding both and diffing them found 3 responses per
run that the page never sees, identical across two $0 image runs:
play.google.com/log, and two recaptcha/enterprise/* (one application/binary), plus
one dedicated worker, recaptcha/enterprise/webworker.js. Surveys #1 and #2 and
this PR's own video run each missed those three, and both sibling notes now say
so. None is a channel, and with the wider listener plus Network.webTransport*
registered for the first time here, the count is still zero WebSocket, SSE and
WebTransport -- the conclusion stands on a wider surface than it was argued on.
Target.setAutoAttach was deliberately not used: flattened child sessions arrive
with a sessionId Playwright does not route to a CDPSession, so those events would
drop silently, which is a detector that looks armed and is not.

THREE INSTRUMENT BUGS, which are the recurring failure here rather than the Flow
surface. Keying dispatch times by url[:200] would have destroyed the result --
all 8 jwpduf URLs are byte-identical at that truncation, measured -- and was fixed
before the run. protocols keyed at [:300] against events at [:200] left 62
responses including every poll with no HTTP version; fixed, and no h2/h3 claim is
made from the video capture (spike_two_domain_protocol_survey.py is unaffected, it
truncates both sides at 300). str(record)[:120] truncated the terminal fields, so
"done=True, 1,273,409 bytes" reached stdout and never the evidence file the note
cited them to; sourcing corrected and those fields are now recorded by name, which
also keeps the signed video_url out.

COST SAFETY. --runs defaulted to 2 in both modes, so appending --mode video to the
committed image invocation silently doubled a real bill while the banner computed
that total and printed it. Now 1 for video, 2 for image. Page check-in moved into
finally: except Exception does not catch Ctrl-C and the video arm holds a pooled
page for up to poll_timeout_s.

ALSO. check_doc_links.py now audits docs/superpowers/spikes/ whole instead of by
hand-maintained allowlist -- every spike note ever written was outside the gate;
all 34 pass, so nothing is grandfathered (29 -> 63 files). The third-party host
census is named once, because ogads-pa has been re-derived as "not Flow" in three
separate notes; none of those hosts should ever be blocked or route-aborted, and
gflow has no machinery that would, which is correct for a project whose premise is
that Google's stack rejects browsers advertising automation.

Reviewed by an 8-dimension council (D0/D2/D3/D5/D14 green; D1/D4/D9/D13 yellow);
every must-fix applied. All local gates green; CI 16/16 including SonarCloud.
…ing link (ffroliva#809)

README gains a Support section with one-click sponsor links, a Gold logo row at the top and a hall of fame; docs/SPONSORS.md lists every tier (mirrored to the website). scripts/ci/update_sponsors.py regenerates both blocks from GitHub Sponsors, rendering only public sponsorships with escaped, validated data; .github/workflows/sponsors.yml runs it daily and commits only the three generated pages to develop. pyproject.toml adds the Funding URL. No CLI or MCP surface changes.
It had been sitting untracked AND un-ignored in the repo root -- the state one `git add -A` turns into an accidental commit. Tracking it deliberately closes that; ignoring it was the other valid answer.

The file is gflow-cli-specific (`wing: gflow-cli`, 11 rooms, 39 exclude patterns) and the miner reads it from THIS project's root, so it is this repo's own config rather than a sibling-repo fixture. Its comments carry measured tuning, not preferences: source and generated data are deliberately not mined because memory should hold what the repo cannot give back -- code is already here at full fidelity and greppable, and chunking it severs function bodies from their call sites (.py alone was 12,797 of 19,951 drawers here, 64%). The `diary` room carries no keywords on purpose: log/notes/session hijacked USAGE.md into 180 drawers.

Two other repository names appeared in those comments as supporting evidence. This repo is public and neither name appeared anywhere else in it, so they are described positionally instead; every figure and every lesson is unchanged. Five exclude patterns are inert against the miner's current readable-extension set and are kept deliberately, with a note saying why, so the next reviewer does not re-derive it.
…alance not found

Two zero-credit runs of the pre-registered probe. denon82 landed on /about
(unmeasured). ci-probe: the composer settings pane states the declared
cost before submit ('Generating will use 0 credits' for an image model);
no price on the wire (HTrJv is a model catalog, tRARke the tools gallery);
no balance in page, aria or watched responses; the labs credits route
returns 401. A cap on declared price is buildable; a balance comparison
stays blocked on ffroliva#795. Refs ffroliva#795
…t and spiked

Umbrella plan from the 2026-09-14 ecosystem review. Five-persona predict
review took it from STOP to CAUTION (6/10): the Pillow fix gains a
pre-flight so a missing chain extra fails before a paid clip, WS2 is
redesigned around Flow's declared per-model price, onboarding waits for
the ffroliva#791 login fix, and WS10 adds a Claude Code plugin marketplace plus
a distribution catalog. WS2 and WS4 decisions are backed by spikes.
Council review of ffroliva#812 ran 8 dimensions (D0 mechanical CI, D1-D5, D9, D13,
D14). Consensus was YELLOW with 22 must-fix items; all are applied here.

Spike script — three of these invalidate how the write-up should be read:

- No `finally:` anywhere. The capture was written after the `async with
  build_client(...)` closed, so any raise inside lost every response and DOM
  read collected. Wrapped the body in try/finally that gathers pending
  recorders, screenshots and writes the capture while the page is still alive.
- `except Exception: body = ""` made an unreadable body indistinguishable in
  the capture from one carrying no credit data -- which is exactly the
  negative sections 2 and 3 report. Now records `body_error` (class name only)
  and prints a count of unreadable bodies.
- Only the route *count* reached stdout, so the rpcid inventory in section 2
  was re-derivable only from a gitignored capture that is not retained. Now
  prints every route id with its max size.
- `argparse` had no `description=__doc__`, so `--help` printed nothing.
- `[contenteditable="true"]` was re-typed rather than imported from
  `migrated_composer.COMPOSER`.

The try/finally is exercised, not just reviewed: fault injection (goto raises,
no browser) confirms the capture lands on the failure path with pre-failure
state intact and the screenshot taken before teardown.

Spike doc:
- Sections 2 and 3 marked provisional -- their numbers came from the
  instrument before the fixes above.
- Section 2 downgraded to UNMEASURED for a paid model: the script reads only
  the first 12 model-token neighbourhoods per body, and the pre-registered
  test required "integers matching the pane's number" while the pane read 0,
  so its antecedent never held.
- N=1 stated up front; every conclusion scoped to the profiles we can test.
- The labs 401 attributed to these profiles' credential state, not to the
  route -- host membership does not predict capability.
- Script cited as a markdown link so check_doc_links can see it; Evidence
  filename corrected to the name the script actually writes; Run and Design
  header lines added to match sibling spikes.

Plan:
- WS1 rewritten smaller. `cli_video.py:942` imports `chain` as the first
  statement of `_run_chain`, so PIL already fails before any spend -- only the
  error type is wrong. Making `av` eager and wrapping that one import beats a
  lazy PIL plus a find_spec pre-flight that rebuilds the earliness it gave
  away, and drops a test that would have passed without the fix.
- Task 1.3 loses the pkgutil walk; the `.[chain]` import line is the gate.
- Locale-invariance added to the per-PR contract, both halves: the spike asked
  for a structural anchor AND a locale-independent number parse, and WS2
  carried only the anchor.
- WS2 drops the per-process cumulative budget and exit 39; WS3 uses the
  existing `_cli_helpers` validator, not a third copy of the project-id regex.
- WS10 tasked out, and now also curates `.codex-plugin`, which ships all 18
  skills -- release, check, pr-council-review included -- to Codex users today.
- WS9 dropped rather than deferred; WS0 and WS10 added to the definition of
  done; private repo name and a private-analytics ranking removed.

Refs ffroliva#795
`gflow serve` accepted a shared secret via GFLOW_CLI_DAEMON_TOKEN /
GFLOW_DAEMON_TOKEN and refused to bind a non-loopback address without one, but
the token was never checked on a request. It gated startup only, so a
no-token, wrong-token and correct-token client were all served identically.
`git log -S` confirms no release has ever enforced it.

Fix: `build_app()` builds the Starlette app for either transport and, when a
token is configured, wraps it in a pure-ASGI Bearer middleware. Missing header,
wrong scheme or wrong token -> 401 with `WWW-Authenticate: Bearer`, compared
with hmac.compare_digest. Both `run_http` and `run_sse` go through it, so the
tests cover the real wiring rather than a middleware in isolation.

Three details that are load-bearing:

- Auth is added LAST, so it is the outermost layer: an unauthenticated caller
  gets 401 and cannot probe the transport-security configuration behind it.
- `host` is forwarded to the SDK's app builders. The SDK auto-enables
  DNS-rebinding protection only when it sees a loopback host, so dropping the
  argument would have silently disabled protection that works today (measured:
  bad Host -> 421, bad Origin -> 403 on a loopback bind).
- The app is rebuilt per call; its session manager is single-use.

Verified as an A/B against released 0.74.0, same probe, loopback, --no-spend:

  BEFORE  no token 200/200 (13 tools)  wrong 200/200  right 200/200
  AFTER   no token 401/401 (0 tools)   wrong 401/401  right 200/200

19 new tests in tests/mcp/test_serve_auth.py; `tests/mcp/` 212 passed. Two
tests in test_server.py asserted the exact call this replaces
(`run_streamable_http_async` / `run_sse_async`) -- their intent (path, host,
port, stateless_http untouched) is kept, re-pointed at the new seam, because
the old assertion pinned the vulnerable wiring.

docs/MCP.md and docs/CONFIGURATION.md described the token as bind-gating only,
which is no longer true.
…-migrated

docs(plan)+spike: community-feedback uplift plan, credits on flow.google.com
…yload

`/plugin marketplace add ffroliva/gflow-cli` then `/plugin install gflow@gflow-cli`
installs the gflow-cli and video-production skills and registers the MCP server
in one step.

The bug this also fixes: `.codex-plugin/plugin.json` declared `"skills":
"./skills/"` and `.agents/plugins/marketplace.json` declared `path: "./"`, so
both shipped all EIGHTEEN skills -- release, check, pr-council-review, sonar,
doc-review, issue-resolve -- to every Codex and ChatGPT-desktop user. Those
drive this repo's own development lifecycle; an agent that reads
release/SKILL.md will try to cut a release of whatever project it is pointed
at. All three channels now point at one curated payload.

The payload is generated, never hand-edited:
scripts/ci/generate_plugin_skills.py copies the two user-facing skills out of
skills/ and rewrites only the links that climb out of the repo
(../../KNOWN_ISSUES.md -> an absolute URL). Intra-skill and sibling-skill links
still resolve under the plugin's own layout and are left alone. `--check` runs
in CI, the same contract generate_website_docs.py already provides for
website/docs/.

Spend consent. Claude Code starts a plugin's MCP servers automatically when the
plugin is enabled -- there is no separate prompt for the server. This one drives
the user's own Google account and Veo video bills their credits, so the plugin
ships `defaultEnabled: false` with a required acknowledgement. Verified: install
reports "This plugin is disabled by default" plus "1 userConfig option not yet
set (1 required)".

Live-verified in a clean CLAUDE_CONFIG_DIR: marketplace add -> install -> enable
-> 9 files, 2 skills, 0 maintainer-only skills, mcpServers wired to `gflow mcp
run`. `claude plugin validate` passes for both manifests.

That install run found a real bug in the generator: it deleted stray files but
left their empty directories, so --check went green while skills/release/ sat in
the installed tree. Directories now count as strays; A/B'd both ways.

Two pre-existing assertions in tests/scripts/test_codex_plugin.py pinned the old
behaviour (`skills == "./skills/"`, `path == "./"`). They were asserting the bug,
so they are updated with the reason inline.
…paid clip

`uvx --from "gflow-cli[chain]==0.74.0" gflow video chain one.jsonl --dry-run`
exits 1 with a generic "Unexpected error ... file a bug". The extra installs av
but not Pillow, and media.py imports PIL at module level.

Three changes:

- pillow>=12.3.0 joins the chain extra. It was declared only in the dev group,
  which is why this was never seen in a checkout.
- `import av` moves to module level in media.py, beside `from PIL import Image`,
  so both optional deps fail at the same point. It was lazy inside
  _decode_frame, which runs only AFTER a link completes -- so a missing av
  failed after the user had already paid for link 0.
- The import block at cli_video.py:942 -- the first statement of _run_chain,
  ahead of the manifest parse, --dry-run, the confirm prompt and any client --
  is wrapped in try/except ImportError and re-raised as the existing
  FrameExtractionError (exit 20), naming the extra and both packages.

Rejected: a lazy PIL import plus a find_spec pre-flight. It makes the import
late, then adds a second mechanism to restore the earliness it gave away,
carrying a package tuple that can drift from the extra it mirrors.

A SECOND, INDEPENDENT BUG surfaced while making the red tests green.
`_cli_helpers._handle_gflow_error` interpolated detail and remediation_hint into
a Rich markup f-string unescaped. Rich reads `[chain]` as a style tag, finds no
such style, and silently drops it -- so every `install gflow-cli[chain]` hint
the CLI has ever printed rendered as `install gflow-cli`, advice that reinstalls
what the user already has. Verified directly: unescaped renders
"pip install 'gflow-cli'", escaped renders "pip install 'gflow-cli[chain]'".
--json was never affected, which is why nobody noticed. Fixed at both render
sites (_cli_helpers.py:310-312 and cli.py:316) rather than by rewording the new
message, so it covers every error class.

A/B against the released wheel, three arms:

  control  0.74.0 + [chain], missing manifest -> exit 1, ModuleNotFoundError
  arm A    fixed tree + [chain] -> all three import; encoded a real mp4 and
           extracted a frame (691-byte JPEG, magic verified)
  arm B    fixed tree, no extra -> exit 20, and `gflow-cli[chain]` survives to
           the console, proving the escape fix in a real terminal

Two pre-existing tests asserted premises this invalidates, both explained in
their docstrings: test_media.py blessed the late `av` guard that fired after
billing, and test_errors.py pinned the old remediation string verbatim.

Full suite: 4330 passed, 7 skipped.

Closes ffroliva#813
Council review found the escape fix from the previous commit was HALF-APPLIED:
it covered the two sites that rendered the new chain message and missed the
rest, which is the [[review-findings-get-half-fixed]] shape this project keeps
repeating. A bracket-bearing hint really exists on the missed path --
`api/_engine.py` tells the user to `uv pip install gflow-cli[patchright]`, and
`cli.py` rendered that ConfigurationError detail unescaped.

Proven on the real in-tree hint:

  UNESCAPED has '[patchright]': False
  ESCAPED   has '[patchright]': True

Escaped every site that interpolates error text into Rich markup, across
_cli_helpers.py, cli.py, cli_movie.py, cli_run.py and cli_scene.py.

Fixing sites one at a time is what let this recur, so the durable part is
tests/test_rich_markup_safety.py: it walks the AST of every module under
src/gflow_cli and fails on a console.print f-string that interpolates
exception text without escape(). It carries its own negative control --
a test asserting the guard flags the bad shape and passes the good one -- so
it cannot quietly become a no-op.

The guard is scoped to error text (`exc`, `.detail`, `.remediation_hint`)
rather than every interpolation. Any interpolated value can lose a `[...]`,
but error text is the class that produces wrong INSTRUCTIONS, and it is where
this project writes bracketed package names. Two rounds of tightening removed
six false positives: `.title` matches a tool spec, a project record and a
movie manifest, and `type(exc).__name__` is a class name that cannot carry a
bracket. Widening the set later is cheap.

661 passed across tests/cli, test_errors, test_media, test_chain and the guard.
… bump site

Council review of ffroliva#815 found three things worth correcting.

The docs overclaimed the consent step. `acknowledgeBilling` has exactly one
occurrence in the tree -- its own declaration -- and `claude plugin enable`
succeeds without it being set, so calling it something the plugin "asks you to
acknowledge before it runs" was not true. `defaultEnabled: false` is the
control that actually holds; the userConfig field is a UI prompt. Both README
and docs/MCP.md now say so, and point at `--no-spend` as the hard guarantee,
since it never registers the credit-spending tools at all.

`/plugin marketplace add ffroliva/gflow-cli` reads the repository's DEFAULT
branch, and this project's default is `develop`. So a user installing the
plugin tracks the integration branch while the manifest version and the
rewritten doc links both name the last release -- three snapshots in one
install. Documented rather than papered over; pinning the marketplace source to
a tag is the eventual fix and needs the plugin to exist on `main` first.

plugins/gflow/.claude-plugin/plugin.json is a fifth place the version is
declared and nothing bumped it. RELEASE.md step 6, its `git add` line, and
skills/release/SKILL.md now name it, so the release run has a documented bump
site instead of discovering a red test.

Also from the review: the byte-identity test restated the generator's link
rewrite rule inline, a third copy that could drift while the test still passed
-- it now calls the generator's own `render()` through a shared loader helper.
Dropped `test_codex_version_tracks_pyproject`, a byte-identical duplicate of
the assertion in tests/scripts/test_codex_plugin.py.

336 passed; ruff, hygiene, doc links, PII guard, website mirror, council
memory and the plugin drift gate all green.
…a, PyPI polish

Three things, all about how people find and install this project.

1. docs/DISTRIBUTION.md -- every channel, with audience, how to submit,
   requirements, status and a last-verified date per row. Every row was checked
   live today, and the ones that could not be checked say UNVERIFIED and name
   what blocked them. It records what the research actually found rather than
   what we assumed:

   - We are ALREADY listed on MCP Market and on skills.sh (21 installs), and
     twice in Arnon-hs/open-source -- none of which anyone submitted.
   - Arnon-hs states we are "available under the Python licence". We are MIT.
   - printing-press-library does NOT list us; our one mention is under
     "Sources & Inspiration" in a competing Go CLI's README.
   - Smithery cannot list a PyPI-distributed server at all; PulseMCP has
     submissions closed and auto-ingests from the official MCP Registry instead.
   - skills.sh indexes skills/ directly, so it still surfaces the
     maintainer-only skills the plugin work curates out of the other channels.

2. server.json for the official MCP Registry, plus tests/test_server_json.py
   pinning version lockstep, the schema's 100-char description cap, the
   one-slash name pattern, the mcp-name ownership token in the README, and that
   the command it advertises actually exists.

   That last test exists because of a blocker that would have shipped a broken
   listing: the registry builds `uvx <identifier>` from the PyPI identifier and
   has no field for a differently-named executable, but [project.scripts]
   defined only `gflow` and `flow`. Measured against 0.74.0, `uvx gflow-cli mcp
   run` fails with uv's own "Use `uvx --from gflow-cli <EXECUTABLE-NAME>`
   instead". A `gflow-cli` console script is added.

   check_repo_hygiene's version gate now covers server.json's two version
   fields, tolerating absence so a fork without one is not failed.

3. PyPI metadata. The summary described only image-to-video and never mentioned
   MCP; added the Documentation/Repository/Changelog sidebar links and ten trove
   classifiers, each checked against the official 895-entry list.

Also drops "unofficial" as a label from the summary, README, docs site,
llms.txt, ROADMAP, the skills and DISCLAIMER.md. The substance is unchanged and
still prominent: the README warning block reads "alpha and reverse-engineered --
not affiliated with Google", and DISCLAIMER.md still opens "not affiliated with,
endorsed by, sponsored by, or otherwise connected to Google LLC" -- only the
word "unofficial" left its first sentence. It was leading with a negative in the
one line PyPI shows in search results.

Refs ffroliva#601
ffroliva and others added 29 commits September 16, 2026 20:11
…version

A package manager replaces files in place, so an interrupted upgrade leaves a
venv whose metadata reads perfectly and whose imports are dead. `gflow update`
only ever re-read the version, so it called that state "still 0.69.0" and sent
the user to a plain `uv tool upgrade` — which cannot repair it: it reads the
same intact metadata, finds it current and changes nothing.

Measured on Windows (ffroliva#848): an aborted native-dependency swap left every command
dying on `AttributeError: module 'greenlet' has no attribute 'greenlet'`, and
only `uv tool install "gflow-cli==<version>" --force --reinstall` completed.

After the manager runs, import gflow-cli in a fresh isolated interpreter before
looking at any version. `-I` so a `gflow_cli` directory in the caller's CWD
cannot answer for the venv; a probe that could not run at all is never reported
as breakage.

Refs ffroliva#848
….com

The auto-close detector watches the labs.google session endpoint, and for a
migrated account labs hands off without ever minting a session — so the one
oracle it had could not answer. The window stayed open for the full 600 s while
the banner promised gflow would close it, and the timeout then propagated past
`_verify_and_record`, so a sign-in that had completed perfectly was discarded
unread and the user got exit 12 for a login that worked.

Two changes, and neither moves the authentication decision into a cookie.

Stop signal: when the labs oracle answers GOOGLE_SESSION_ONLY and the jar
carries both halves of a migrated session, stop waiting. That ends a wait which
had no other way to end; `verify_flow_profile` still asks a server about what
landed on disk and is still the only thing that can call a login successful.
Gated on `raise_on_close=False`, which already means "this caller owns that
fallback oracle" — `--browser internal` has none and keeps waiting as before.
Reached only past `_is_safe_to_probe_session`, so a user on Google's password
screen never gets there and their window is never closed out from under them.

Timeout: read the profile before failing. A sign-in the detector missed is
reported as the success it is; otherwise the timeout's own wording stands,
because it is the message that explains the wait. This reverses a contract the
suite pinned as "verification must not run on timeout" — that described the
control flow rather than requiring it, and ffroliva#849 is what it cost.

The poll now returns the status that ended the wait rather than a bare email:
a session can be proven without a label, and `str | None` made "no email" and
"window closed" indistinguishable, so the caller skipped both the notice and
the cookie-flush wait for a login that had just succeeded.

Closes ffroliva#849
`[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}` has no nested quantifier, so this is not
catastrophic backtracking — it is the plainer problem that `findall` retries
from every start position and each start rescans its run before failing to find
an `@`. Measured: 5 000 chars 0.12 s, 10 000 0.48 s, 20 000 2.00 s, 40 000
12.07 s. Clean 4x per doubling, so 200 KB would be minutes.

It is reachable, not theoretical. The class accepts `_`, `-`, `.` and `+`,
which is the entire URL-safe base64 alphabet, and the body being scanned is a
1.28 MB myaccount.google.com response full of exactly those blobs. The scan
runs synchronously inside an `async def`, so a stall blocks the event loop and
`CancelledError` cannot land until it returns.

Anchoring on the literal `@` lets CPython's `re` use its literal-prefix fast
search, and the local part is then read backwards over a bounded 64-character
window — the RFC 5321 cap, which is also what keeps each `@` constant-cost.

Not fixed with a leading `\b`, which is the tempting one-character answer: the
class also matches `-`, so a base64 blob still has a word boundary roughly
every sixteenth character. That is a 16x constant on a curve that stays
quadratic.

Same output for every address shape, including the Workspace and custom domains
ffroliva#791 turned on. 40 000 chars now scan in under a millisecond, 440 000
base64-shaped ones in 0.2 ms.

Closes ffroliva#852
…ts-broken-install

fix(update): detect a half-replaced install instead of reporting its version
SonarCloud python:S8786 on `[\w.+-]+\Z`, and it is right: that pattern is
super-linear over whatever it is searched in. The 64-character window bounded
it in practice, but neither a scanner nor a reader can see that from the
pattern — and "it is fine because of a constant three lines down" is exactly
the reasoning that rots.

Python's `\w` is documented as "`str.isalnum()` or underscore", so the scan
reproduces `[\w.+-]` character for character, is O(1) per character, and is
visibly so.

Fuzzing the replacement against the pattern it replaces turned up a real
difference this had introduced, unrelated to Sonar: `findall` resumed at the end
of its previous match, so a local part could never reach back into one, while an
`@`-anchored scan could. Two `@` within the 64-character window is the only
shape where it shows — 2 differences in 4 000 random bodies, now 0 in 20 000
with the previous match's end as a floor, and pinned by a test.

Refs ffroliva#852
…in-autoclose

fix(auth): close the sign-in window for an account served flow.google.com
# Conflicts:
#	CHANGELOG.md
#	src/gflow_cli/auth/verification.py
…-scan

fix(auth): make the address scan linear in the migrated-host probe
ffroliva#848's three bad states came from an MCP server this repo tells people to
install. An editor with gflow registered starts one `gflow mcp run` per
session; they are long-lived and usually plural, and on Windows they hold files
inside the install open, so a package manager can abort part-way and leave the
environment broken.

The advice to stop those processes already existed — in the remediation
`gflow update` prints once the damage is done. Saying it in the upgrade section
is the difference between a recovery step and not needing one.

Also states why `gflow update` cannot pre-flight this itself: it is one of those
processes. `structlog` imports `greenlet` unconditionally, so every `gflow`
maps the venv's native modules, and refusing while any gflow holds the install
would refuse every run.

Mechanism reported with a reproduction by Cstanish127 on ffroliva#824.

Closes ffroliva#848
`configure_logging` renders TEXT logs with
`structlog.dev.ConsoleRenderer(colors=True)`, and structlog's Windows
`_init_terminal` raises `SystemError` outright when colorama is missing.
The call sits in the Click group callback, so it fires before any
subcommand body — a clean Windows install aborted every interactive
command with a traceback that named structlog and never gflow, and the
README's Windows quick-start failed as written.

Nothing in the runtime closure supplied it: structlog keeps colorama an
optional extra and rich ships its own Windows console handling. The only
colorama edge in uv.lock came from pytest, a DEV dependency, so every
developer machine and every CI job had it transitively and no gate could
see the gap.

Declaring the dependency keeps the coloured output the call site intends,
rather than degrading to colors=False. Piped stderr escaped the crash
(AUTO then resolves to the JSON renderer), which is why non-interactive
and CI use never tripped over it.

Reproduced on a clean Python 3.13 venv against develop, then verified by
reinstalling the fix into that same venv: the `gflow doctor` run that
raised SystemError now completes, on both the CLI and the MCP stdio
server. A behavioural test cannot catch the absence (pytest supplies
colorama), so tests/test_observability.py asserts the declaration.

Closes ffroliva#846
…dows-dependency

fix(deps): declare colorama on Windows so a clean install can run (ffroliva#846)
…e holder is installed

Council review of ffroliva#856 converged on three findings.

Three dimensions independently flagged that the new "if an upgrade does
abort" paragraph restated the exit-11 / forced-reinstall paragraph fifteen
lines above it, with less precision — the original also names the repair
command and why a plain `upgrade` cannot do it. Replaced with a
back-reference, so the mechanism is stated once and cannot drift.

The block also contradicted the paragraph immediately before it: that one
says a running `gflow.exe` holding its own launcher is benign and the
upgrade lands anyway, this one said a running gflow leaves the environment
broken. Same-sounding phenomenon, opposite guidance, adjacent. Named the
distinguisher — the launcher you are running versus a different gflow
holding the install's contents.

`tasklist` matches image names only, so it finds the `gflow.exe` shim but
not a server registered as `uv run gflow mcp run`; said so rather than
implying parity with `pgrep -f`, which is now `-fa` so the command line
that matched is visible before anything is stopped. Softened two frequency
claims ("people actually hit", "usually more than one") that a single
reproduction does not support, per the claims discipline in AGENTS.md, and
pointed the citation at the comment carrying that reproduction instead of
the issue body, which still carries the refuted chrome.exe attribution.

docs/MCP.md is the page that installs the lock holder and never said
registering it gates upgrades; one note at the end of § 4 now covers every
client listed there. USAGE.md's own instruction landed 67 lines into the
section, so the section head now carries a one-line pointer.
…olders

docs(update): name the lock holder before the upgrade, not after
…gle.com (ffroliva#831)

Ports `--end-frame` (start+end interpolation i2v) to the migrated host. Refs ffroliva#639.

Contributed by @stgmt, who found that the timeout was happening AFTER a successful
server-side submit — the difference between a broken feature and one that bills you
while reporting failure — and traced it to rpc `nprQif`.

Verified on a second cohort (ffroliva/flow.google.com, 2026-09-17) at $0 via
route-abort: rpcid and both media ids confirmed; model key is cohort-dependent
(`omni_flash_i2v_8s_first_last` here vs `veo_3_1_interpolation_lite` there), so
matching is on key shape. Findings in
docs/superpowers/spikes/2026-09-17-migrated-end-frame-submit-contract.md.

Maintainer follow-up: dropped adoption-by-content (dead for the veo key, and able to
hard-fail an already-billed run), armed the billing guard for t2v, fixed a spike script
that could not run, added the missing attach_end_frame coverage and a zero-credit e2e,
de-vacuumed the BDD scenarios (ablation-verified), stopped an existing e2e from spending
a credit and failing on a labs-only event name, and corrected seven doc sites.

Offline suite 4489 passed; new e2e passes live.
…hored match (ffroliva#860) (ffroliva#866)

`_pick_frame_by_name` matched a picker option with `_exact(name)` --
`^\s*<name>\s*$`. A picker tile is a `mat-icon` ligature followed by the file
name, and a locator reads both nodes as one string, so the option offered for
`hero-ab12cd34.png` reads `imagehero-ab12cd34.png`. The anchors could never
hold: every i2v run on this cohort ended in exit 32, "the frame picker lists no
asset named ...", while the picker had been listing it the whole time. The
reported listing is what pinned it -- the `image` prefix is the icon, not a
rename.

Root cause at migrated_composer.py:1980, and only there. The other three
`_exact` callers (a `mat-icon` ligature, two aspect-ratio radios) match a
node's own text and are left alone. `_mention_by_name` never matches text at
all -- it types `@name`, presses Enter and counts chips -- so the r2v mention
misses seen on the same account are a different defect and stay open.

The match is now containment on the display name, which `_unique_display_name`
makes run-unique (ffroliva#792), so it still cannot bind a stale copy of the same file.

Formalized as e2e, not a mocked proxy: a fake locator returns the list the test
wrote into it and matches the anchored pattern happily, so the mocked version
of this test passes against the bug. `tests/features/frame_picker_binding.feature`
serves the tile through route interception ($0, no Google, no profile, no
credits) and real Chromium reports `imagehero-ab12cd34.png` -- the same shape as
the `images1-cbbc098b.jpg` the issue was filed with.

Red before green, measured both ways with the final test file:
  fix stashed   -> 2 failed, 1 passed (62.22s; the miss is 3/3 searches)
  fix restored  -> 3 passed (5.49s)
The third scenario is the A/B control: an asset the picker genuinely does not
list is still refused with exit 32, so containment is not "bind anything".
…nly on load (ffroliva#859) (ffroliva#867)

A `role="dialog"` promo panel over a freshly loaded editor made `image t2i`
fail as exit 23 -- ".settings-trigger-button did not accept a click within
5000 ms -- it is covered by div.cdk-overlay-backdrop" -- with a remediation
telling the user to file a selector-drift bug, when one click on the page
fixes it permanently and nothing had drifted.

The issue was filed saying the dismissal was ordered AFTER the click. It is
not: `ensure_editor` calls `_dismiss_dialog` at :804, before it waits for the
trigger. It calls it one frame after `domcontentloaded`, before Angular has
rendered anything, so `is_visible()` answers False and the method returns
having done nothing. That is why a blocked run logs NEITHER
`migrated.dialog_dismissed` nor `migrated.dialog_not_dismissed` -- the silence
in the incident's event stream is the evidence, and the "wrong position"
reading does not explain it.

The dismissal now also runs in `_open_pane`, where the image and video paths
take their FIRST click and where the `glue` consent bar is already cleared for
the same reason. It stays best-effort: a modal that refuses to go falls through
to the click post-mortem, which names the backdrop, rather than becoming a
second competing error path.

Formalized as e2e because the whole defect is WHEN the DOM has the dialog in
it. A mocked page is whatever the test constructed before the test ran; it has
no "later", so the broken ordering and the fixed one look identical to it.
`tests/features/blocking_dialog_dismissal.feature` mounts the modal 250 ms
after load through route interception ($0, no Google, no profile, no credits).

Red before green:
  fix stashed   -> 2 failed, 2 passed (22.64s)
  fix restored  -> 4 passed (12.68s)
The two that flip are the dismissals. The other two hold both ways on purpose:
a modal that ignores its close button is still named rather than swallowed,
and an editor with NO modal records no gesture at all -- the control that
matters here, because `_dismiss_dialog` falls back to Escape, and an Escape
pressed on a healthy page would close the settings pane the next line opens.
…rom the CLI (ffroliva#861) (ffroliva#869)

* feat(docs): `gflow docs` — list, print and search the documentation from the CLI (ffroliva#861)

126 pages in `docs/` and nothing in the CLI pointed at any of them, so at the
moment of use the knowledge was unreachable and the reader guessed. The failure
that filed this issue was `video r2v --duration 10` against a host that offers
reference-to-video at 8 s only -- a rule that was already written down and never
found.

    gflow docs                          every topic, with what it covers
    gflow docs usage                    one page, raw Markdown (it pipes)
    gflow docs --search "r2v duration"  the line that answers the question

Read-only and offline: no network, no account, no credits, no database.

THE PAGES SHIP IN THE WHEEL. The user this was filed for installed from PyPI and
has no checkout, so a command that could only print GitHub links would not solve
it. `hatch_build.py` globs `docs/*.md` into `gflow_cli/_docs` (+1.3 MB). The
two-line alternative -- `force-include` plus `exclude` -- was built and inspected
first: hatchling ignores exclusions for forced inclusions, and 148 files it was
told to exclude shipped anyway, 4.3 MB of plans, spikes and PNGs. The glob is
also the list, so the topics cannot drift as pages are added.

SEARCH RETURNS A LINE, NOT A FILE NAME. The rule above lives inside a
4 000-character bullet in `MCP.md`; answering "it is in MCP.md" leaves the reader
where they started. Hits carry `docs/FILE.md:LINE` and the line windowed around
the match. Query terms are ANDed, curated `INDEX.md` shortcut rows rank above raw
body text, and a vague query reports how many hits it held back.

Ranking's limit is recorded rather than papered over: `--search duration` alone
has 133 hits and three orderings were measured, including one that reused
`INDEX.md`'s routing as a reference-vs-record signal. That one is false --
`INDEX.md` links 93 of the 126 pages -- and was removed rather than kept as a
knob that does nothing. `_score`'s docstring says so, so it is not reinvented.

A topic resolves from its slug, file name, either case, or an unambiguous prefix,
and NEVER by joining user input onto a path -- the argument is a key looked up in
the enumerated page set, so `gflow docs ../../etc/passwd` is an ordinary
unknown-topic refusal (exit 11, an existing class; no new exception, no new exit
code). Every printed string round-trips through the console's own encoding, which
is the ffroliva#846 shape from one release ago.

No MCP twin yet, registered in `_MCP_EXEMPT` so the decision is enforced rather
than claimed in a docstring: documentation is a resources provider, not a tool.
Every decision therefore lives in `docs_catalog`, Click-free and returning data,
which is the upgrade path.

Scenario + plan: docs/superpowers/plans/2026-09-17-gflow-docs-command/.
Tests: 40 offline, plus an integration test that builds a wheel, installs it in a
scratch venv and runs the command with the repository nowhere in sight -- the
only one that can tell "it works here" from "it works for the user who filed it".

* fix(ci): allowlist hatch_build.py as a canonical root file (ffroliva#861)

The repo-hygiene gate flags any tracked top-level *.py outside
ROOT_DOC_ALLOWLIST as a stray artefact. `hatch_build.py` is not stray:
hatchling resolves a build hook's `path` from the project root and defaults
to that exact name, so it cannot live under scripts/ without the config
pointing back out to the root anyway.

Same category as the already-allowlisted `conftest.py` — a root Python file
the tooling requires there. Caught by the council's D0 mechanical gate, which
only turns red once the new files are tracked; the pre-commit run before
`git add` was green and would have shipped this to CI.

* fix(docs): apply the council review's findings to `gflow docs` (ffroliva#861)

Six review dimensions ran against bfc0afc. Security found nothing exploitable
(19 hostile topic names, zero reads). The rest found real defects, all fixed
here.

CORRECTNESS
- `_plain()` stripped single `_`, so every curated search hit printed a file
  name that does not exist -- `REFERENCE_STRATEGIES.md` came back as
  `REFERENCESTRATEGIES.md`, in the rows that rank FIRST, and 106 of the 126
  pages carry an underscore. Only `**`, `__` and backticks are stripped now.
- `_read()` passed `errors=` to `read_text`, which `Traversable.read_text` does
  not accept -- a TypeError on the one path that matters, a page read out of a
  zipimport, uncaught by `except (OSError, ValueError)`. Every test passed
  because an editable checkout exercises the *fallback* branch. Now reads bytes
  and decodes.
- The heading bonus fired on `#` shell comments inside fenced code blocks. That
  is what put eight LIVE_VERIFICATION records on top of `--search duration` --
  the noise the ranking was added to remove. Fence tracking, four lines.
- `--search ""` defeated the topic/search mutual exclusion: the guard tested
  truthiness while the dispatch tested `is not None`, so the topic was silently
  discarded. Both test `is not None` now.
- `_snippet` windowed on the first term typed; it now centres on the least
  frequent one, which is the selective half of a two-word query.
- The search table's title reported the SHOWN count while the footer reported
  the total -- "20 match(es)" above "and 116 more".
- Every refusal inherited ConfigurationError's default remediation: "Run
  `gflow config list-transports`", advice about a transport registry printed at
  someone who asked for a page.
- An unknown topic now uses `difflib.get_close_matches`, so `usge` is offered
  `usage`. USAGE.md already promised this; a substring scan could not do it.

BUILD
- The hook refuses to build when it finds no pages. `Path.glob` on a missing
  directory returns empty, and a sdist with `docs/` removed produced a valid
  767 KB wheel carrying zero pages, exit 0 -- and `release.yml` is `uv build`
  then publish with nothing in between that would notice.
- The hook now skips editable installs, which were copying 126 pages into a
  `site-packages/gflow_cli/` that has no `__init__.py`, is never read, and goes
  stale on the next doc edit.

TESTS
- The cp1252 test proved nothing: a mutation replacing every `_console_safe(`
  call site with `str(` left 33/33 green, because the test called the helper
  directly and never ran the command. Replaced with a subprocess run under
  PYTHONIOENCODING=cp1252 across three invocations -- and `_console_safe` is
  gone, replaced by one `sys.stdout.reconfigure(errors="replace")` that also
  covers Rich's own box glyphs, which the helper never did.
- Added: hostile names driven through the CLI (not just `resolve()`), the NUL
  case, `docs ""`, `--search ""`, the remediation text, the typo suggestion,
  and the code-fence scoring.
- Loosened the acceptance assertion from "MCP.md is first" to "MCP.md is in the
  top three": a heading scores +10, so a future page could take first place
  without the feature being any worse, and an ordinary docs edit should not red
  the build.
- Corrected this file's own claim that the wheel test is opt-in. `integration`
  is not in `addopts`' exclusion list, so it runs on every plain pytest and on
  PR CI -- which is the right outcome and was worth stating accurately.

DOCS
- `gflow docs` was missing from every place the command surface is enumerated:
  AGENTS.md, llms.txt, skills/gflow-cli/SKILL.md, and the synopsis block of the
  very USAGE.md section this feature added.
- Two numbers in that section were wrong (133 lines, five hits) and one promise
  was false until difflib landed.
- CHANGELOG said +1.3 MB; measured, the wheel goes 0.74 -> 1.32 MB, so the cost
  is +0.58 MB compressed.

NOT FIXED, ON PURPOSE
- `A_B.md` and `A-B.md` would collide on one slug. Measured: zero collisions
  across all 126 pages. A guard for a condition that does not exist is a guard
  nobody can test.

* fix(ci): regenerate the plugin skills mirror after the SKILL.md edit (ffroliva#861)

CI's three `test` jobs failed in 11-14 seconds -- too fast to be tests. The
cause was the "Plugin skills in sync" step, which runs BEFORE pytest:

    plugins/gflow/skills is stale -- regenerate with:
      python scripts/ci/generate_plugin_skills.py
      DRIFT: plugins/gflow/skills/gflow-cli/SKILL.md

Adding the `gflow docs` line to `skills/gflow-cli/SKILL.md` left its plugin
mirror behind. `website/docs` has the same shape and I did regenerate that one;
this generator was simply not in the local gate I ran, so the drift reached CI.
Seven version sites, CHANGELOG migrated, PROJECT_STATUS rewritten, and the
live-verification ledger extended to cover the three PRs that landed after this
release branch was first cut.

The branch sat at 3bf8ad5 with its prep already written. ffroliva#866, ffroliva#867 and ffroliva#869
merged into develop after that, so develop was merged in and their CHANGELOG
entries moved under [0.78.0] rather than left under a heading that no longer
exists. Per step 12 of the release skill, newcomers each need their own 4b row:

- ffroliva#860 (Frames picker binding) is VERIFIED against live Flow. It is the one item
  in this release that could not be proven credit-free -- the only evidence that a
  start frame binds is a real generation -- so clips were generated and downloaded
  on a migrated account, every run logging migrated.frame_uploaded ->
  frame_bound -> submit_observed -> download. frame_bound is the event that could
  not fire before this release.
- ffroliva#859 (promo modal ordering) is recorded as NOT OBSERVED. Its dismissal path ran
  on every one of those runs and no modal ever appeared, so nothing here proves
  the ordering fix; the $0 route-intercepted e2e is what covers it. Recorded
  rather than folded into ffroliva#860's green.
- ffroliva#861 (gflow docs) is verified from the built artefact, which is its whole
  contract: 127 topics, docs/FILE.md:LINE search hits, no network, no checkout.

PROJECT_STATUS and the INDEX live-verified row both still claimed the release was
verified at ZERO credits. That was true of the release as cut and is not true of
the release being tagged; both are corrected rather than left flattering.

Gates: ruff, ruff format, pyright, repo hygiene (1159 files), doc links (196
files), website PII, website mirror, plugin skills, check_release_artifacts --
all green. pytest 4567 passed, 7 skipped, coverage 92.47%.

NOT done, said out loud: the shipped-planning-artifact consolidation in step 10.
docs/superpowers carries ~100 plan/spec/spike files from many past releases, so a
this-release-only sweep would be inconsistent, and hatch_build.py cites
plans/2026-09-17-gflow-docs-command/SCENARIO.md as the record of a measurement.
127 upstream commits (v0.73.2 .. v0.78.0) onto the fork's 27.

Conflicts resolved:
- errors.py: FlowHostMigratedError text lists both upstream's end frames and
  the fork's character references.
- EXIT CODE CLASH: upstream released 39 as FlowAccessUnavailableError; the
  fork had given 39 to AvatarUnavailableError. The Avatar gate moves to 40
  (code, CLI help, tests, USAGE, KNOWN_ISSUES, AGENTS.md; CHANGELOG ### Changed).
- client.generate_video: the fork's Avatar pre-flight, then upstream's
  create-project-when-missing (ffroliva#864).
- migrated_composer: upstream's start+end frame docstrings with the fork's
  entity-chip comments; submit observer keeps the fork's reply-shape log and
  upstream's frame-rpcid recording.
- r2v --duration keeps the fork's rule (refuse 4s/6s, allow 10s on
  omni-flash; measured 2026-09-12, newer than upstream's 2026-09-06).
- cli.py: `escape` is now module-level upstream; `datetime` stays local.
- CHANGELOG: fork items stay under [Unreleased], upstream releases follow.

Follow-ups the merge required:
- test_likeness_eligibility: pass a project_id; since ffroliva#864 a missing one is
  created first, which that guard is not about.
- plugins/gflow/skills regenerated from skills/.

Gates: ruff, format, pyright, doc links, website mirror, hygiene, PII,
council memory; full offline suite 4703 passed after the two fixes above.
A profile whose cached deadline has passed is re-minted at client start,
which uses the one attempt per client; the test then cleared the cookie and
expected a second. Reset the state so it measures the reactive path. Live:
two auth.flow_session_refresh outcome=ok (startup + reactive), 1 passed.
…ot Enter

The @ picker lists media and characters together, unranked, and the mention
was committed with Enter - the FIRST option. Live 2026-09-18 on kittinansr2,
`@tun` offered fourteen `tun_portrait-*.jpg` files before the character
`Tun`, so every character run refused at the chip read-back (exit 32, no
credits spent) and a character whose name any file shared was unusable.

attach_character_entities now: `@` -> the picker's Characters tab (by its
`accessibility_new` ligature, locale-invariant) -> search in the picker's own
box (the tab click moves focus off the composer) -> click the option whose
title matches exactly (or the sole option). A miss is closed with Escape,
which also drops the `@` (measured), instead of Backspace, which could eat a
chip. The entity kind + id read-back is unchanged.

Spiked live before the change (free, no submit): the tab narrows `tun` to one
option, clicking it commits `data-reference-type="entity"` with the right
entity id, and the `@` is consumed on success and on Escape.

Live after: `video t2v --model veo-lite-lp --reference-entity 42a8618b...
--reference-entity-name tun` -> exit 0, 8.0 s 720x1280 h264, character on
screen.

Docs: USAGE (Characters tab; no `@name` in the prompt - exit 29 on migrated),
KNOWN_ISSUES (character create/list broken by the retired labs RPCs, measured
the same day), CHANGELOG.
…InitialData is retired

`@Name` in a prompt and `gflow character list` both read the project's
characters from labs `flow.projectInitialData`, which on migrated accounts
now answers 404 "Flow RPCs have been deprecated and disabled" (measured
2026-09-18). Every prompt carrying an `@` stopped at exit 29 before
submitting; `character list` at exit 7. Reported by the owner running
`video t2v "@tun ..." --model veo-lite-lp` on kittinansr2-reel.

On that 404 (and only that - other labs failures still surface),
list_characters now reads the flow.google.com app's own project load: a
temporary page opens /project/<id> and keeps the `Zzl0ze` batchexecute reply.
Its payload holds one row per character, located by shape, not position:

  [project_id, entity_id, null, [1, name, [[[wf]..],[[wf]..], personality],
   true], thumbnail_media_id, [w,h], created, updated]

Found by a free network spike of a project open (no submit), then parsed
against the real captured body before any code was wired.

Live after the fix:
- `character list --profile kittinansr2-reel` -> exit 0, Tun, 3 workflow ids
- the owner's exact `video t2v "@tun ..."` -> exit 0, `mention_resolved` ->
  `character_entities_attached`, 8.0 s 720x1280 h264 + aac, Tun on screen

`character create` stays labs-only and broken on these accounts
(KNOWN_ISSUES). The earlier USAGE line "do not write @name in the prompt"
is replaced - it is no longer true.
@kittinan
kittinan merged commit 5422674 into main-tun-tmp-fix Sep 18, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants