Isolates whether the 403 is curl/UA-specific (vs the plain fetch() the
failing script actually uses) and whether it's specific to the listing
endpoint vs a non-listing org-detail GET.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
AGE-497: same Sentry token verified 200 locally returns 403 from GitHub
Actions runners on /organizations/{org}/projects/. Add a workflow_dispatch
probe that prints the runner's public egress IP and retries the exact
failing call with verbose headers, to distinguish an Actions-IP block from
a token/scope problem before deciding the fix.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Two things the v0.4.15 cut got wrong were things this doc told it to do.
- PUBLISHING.md step 1 said not to bother hand-bumping `android.versionCode`.
That is true for Play (CI overrides it with run_number+100) and false for the
two channels that carry most of the install base: F-Droid and direct APK key
upgrades off versionCode, so reusing the previous one means the release is
never offered to anyone who already has that code. Step 1 now lists all four
places to bump plus the changelog named after the code, and step 5 adds the
two channel checks (GitHub release — the source the in-app update check polls
— and the F-Droid index) that were previously implied to be unnecessary.
- docs/playstore.md release history: v0.4.15 at production versionCode 153,
the first release to reach production from a tag push alone (#177), plus the
superseded 152 from the pre-#180 tag.
Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
A window that spans the 2026-08-14 14:22Z production rollout contains devices
that could not possibly have run the gate. Its rate is neither a baseline nor a
result, and it prints identically to both. This was not hypothetical: a
`post=08-14T07:00Z..now` window (84% of it pre-rollout) was run against this
script and reported opencode-mobile *rising* to 4.44/h.
`noise-gate-report.mjs` shipped with the same defect built into its default:
`post = now-7d..now` straddles the rollout on every run before 08-21, diluting
the after-rate toward baseline - biased toward grading the gate as ineffective
on exactly the dates the ticket schedules its reads (08-17, 08-21).
- sentry-volume-report: `--since-rollout` reads the instant from the release
history table in docs/playstore.md (production versionCode >= 150, earliest
such release, so a later v0.4.15 does not restart the window) and splits
there. Every window is labelled [pre]/[post]/[mixed]; mixed prints how much
of it predates the gate, a young post window prints its uptake age, and an
unparseable table reports "unknown" rather than assuming post.
- noise-gate-report: defaults post to the rollout instant, returns UNGRADED for
a mixed/unknown post window, and pins the baseline to the documented
post-box-bot-fix window instead of a 7d lookback that dragged ~22k/mo of
already-fixed box-bot volume into the org outlook (it read "MISSES by 18,612"
for a dead reason; now 628/mo, clears).
- before_send == 0 is now reported as expected in a pre/mixed/young window and
as a failure only after 24h+ of gated production.
Re-probed every server-side lever with a WRITE-scoped token so none of the
answers is a permissions artifact, and corrected the record in docs/analytics.md:
per-key rate limit returns 200 and silently drops the field; error-message
filters return 400 "You do not have that feature enabled" (a plan gate, not
absence - it is the one lever that would reach never-updating installs); spike
protection is not 403-unavailable, it is already enabled everywhere and simply
does not fire on sustained baseline volume.
Co-authored-by: engineer <engineer@macbookpro.lan>
The v0.4.15 release commit (f564869) bumped package.json and app.json
`expo.version` but left `android/app/build.gradle` at versionCode 41 /
versionName "0.4.14" and app.json `android.versionCode` at 41. Consequences,
all observed on tag v0.4.15:
- `npm run check:versions` fails, so the F-Droid publish (run 31820921979)
died at its first step and the `release` job in build.yml never ran — no
GitHub release exists for v0.4.15. That is the source the new in-app update
check polls, so the mechanism this release exists to ship had nothing to
find.
- versionCode 41 is v0.4.14's. F-Droid and every direct-APK install key
upgrades off versionCode, so even a successful publish would not have been
offered to the 0.4.10/0.4.14 cohort. Play was unaffected only because the
publish workflow overrides the code with run_number+100.
Fix is the missing half of the release bump: versionCode 42 / versionName
0.4.15, plus the changelog files named after the code (distribution/ for the
record, fastlane/ for F-Droid).
check-version-parity.mjs now also requires distribution/changelogs/<code>.txt
to exist and to describe the version being released, and the fastlane copy to
exist. A stale versionCode is otherwise internally consistent and silent;
verified it discriminates — code 41 with version 0.4.15 fails, 42 passes.
Tests: npm test 320 pass.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Carries the two AGE-110 mechanisms: the in-app update check (#179) and the
first release whose tag push publishes straight to Play production (#177).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
AGE-110: 64% of 30d-active users sit on v0.4.10 and 0.2% on the newest
build, so a client-side fix (the AGE-105 Sentry noise gate) reaches almost
nobody. Play hygiene fixes one channel; the direct APK, the self-hosted
F-Droid repo and third-party mirrors have no update mechanism at all — a
device installed from a downloaded APK has literally no way to learn a newer
version exists.
- src/lib/update-check-policy.ts: pure, node --test'able decision logic —
numeric version compare (a string compare puts 0.4.10 BEFORE 0.4.9, i.e.
it would have told the largest stale cohort it was current), a 24h check
throttle that survives a backwards clock, per-version dismissal, and a
cached last-known-latest so the affordance survives between checks
- src/lib/update-check.ts: Android-only runtime wiring. One unauthenticated
GET per 24h to the GitHub releases API (every non-Play channel is
downstream of a GitHub release; expo-updates cannot replace a native
binary, which is what this cohort needs). Never throws.
- UpdateBanner on the sessions list: one dismissible strip, no modal.
"Not now" sticks for that version only.
- Settings "Version" row showed a hard-coded "1.0.0" for every build ever
shipped. It now shows the real version, plus "0.4.10 -> 0.4.14" when an
update exists (ignoreDismissed: dismissal silences the banner, not the
place a user goes to check).
- en/zh-Hans strings, catalog parity kept.
Tests: 19 new cases in update-check-policy.test.ts; full suite 300 pass,
tsc --noEmit clean.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Play production served versionCode 136 (v0.4.5, 2026-06-22) for eight weeks
because a tag push only reached the `internal` track and production needed a
second, easily-forgotten workflow_dispatch. Sentry release health on 2026-08-14
shows the cost: 64% of 30d-active users pinned to v0.4.10 and 0.2% on the
gated v0.4.14, which caps the AGE-105 client-side noise gate at a small slice
of the error volume it was written to remove.
- non-dispatch runs (tag push / release published) resolve to
track=production, status=completed
- workflow_dispatch keeps its track/status inputs (default internal) for dry runs
- serialize per-ref with a concurrency group so a tag push and a
`release: published` for the same version cannot race two uploads
- job summary records event -> resolved track/status + the real versionCode
- PUBLISHING.md claimed the service account is "internal track only"; run
31807432647 published to production successfully on 2026-08-14, so that
claim is removed rather than worked around
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
The AGE-105 grader multiplies the baseline by `1 - gated_share * efficacy`, so
`gated_share` decides the verdict. Its only source was Play's version share,
which cannot see this app's primary install channels: the self-hosted F-Droid
repo and the direct-APK release, both of which are what the marketing pages
actually point at.
Sentry release health can. Sessions are stored under a separate quota from
errors, so they keep arriving while the org is over its error quota and every
error is rate-limited away — which is exactly when this number is needed. It is
also the right population by construction: a device that sends a session is a
device that can send an error.
First live reading (7d to 2026-08-14, org vibetechnologies):
0.4.10 801 users 78.5% ungated
0.4.12 161 users 15.8% ungated
0.4.14 7 users 0.7% gated
gated share 0.7% (users) / 0.2% (sessions)
v0.4.10 has held ~78% for four weeks and v0.4.12 sat flat at ~16% for three,
so this install base does not auto-converge on the newest build. Until that
cohort moves, the ceiling on any client-side volume reduction is ~21.5%.
Tests pin the parts that fail silently: 0.4.9 vs 0.4.10 ordering, unparseable
releases staying in the denominator (dropping them inflates the share), an
empty window returning null rather than 0, user-vs-session divergence, and a
projection that refuses horizons and flat series instead of inventing uptake.
Co-authored-by: Bertram Gilfoyle <agent@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
The gate ships inside the app binary, so it only runs on devices that took
v0.4.14. Grading it on a raw event count is a measurement error in both
directions: a still-high number at 30% uptake is the gate WORKING (~70% of
baseline is the model's own prediction), and a dip from a quiet weekend is not
efficacy. The scheduled re-reads on 08-17 / 08-21 / 09-05 would have hit the
first one first.
noise-gate-report.mjs folds Play's version share into the comparison
expected_post = baseline x (1 - gated_share x 0.969)
where 0.969 is measured, not guessed (90d replay in sentry-noise.test.ts), and
grades the measured rate against that instead of against the 100%-uptake
endpoint. It reports the endpoint separately, so 'is it on track today' and
'will it clear the 3,500/mo org gate' stop being the same question, and it
inverts the model to print the IMPLIED on-device efficacy so the constant is
checked rather than trusted.
It refuses to grade two windows that look like results but are not: no
client_discard/before_send (nothing ran the gate) and 0% Play share. Absence of
evidence gets its own verdict, UNGRADED.
Runs in CI because neither credential (Sentry org token, Play service account)
exists outside GitHub Secrets — an agent picking up the 08-21 read locally is
stuck otherwise. Weekly cron records the trend regardless.
Verified against the live org: 1.2h after the production rollout it reads
UNGRADED, before_send=0, 4.95/h vs a 4.71/h baseline — which is exactly right,
no device has the build yet.
Refs AGE-105
Co-authored-by: engineer <engineer@macbookpro.lan>
* tools(sentry): add org-wide volume report and fix the metric we measure on
The AGE-105 gate is a measured number, so it needs a repeatable query. It also
needed a correction: `accepted` is the wrong headline. The org is over its error
quota, so Sentry rejects nearly everything and `accepted` reads ~0 for every
project - a blown org and a fixed one look identical on that column. The demand
metric is `submitted` = accepted + rate_limited.
scripts/sentry-volume-report.mjs takes named --window ranges and prints
per-project submitted / accepted / rate_limited / client_discard plus the
per-hour and projected per-month rate, so before/after comparisons run the exact
same query instead of being re-derived by hand each time.
Records the pre-rollout baseline in docs/analytics.md: opencode-mobile at
4.71/h (3,441/mo), 87% of the org's post-box-bot demand, from two windows that
agree to within 0.2%.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* test(sentry): pin the noise gate against 90d of real production events
The gate's unit tests prove it behaves as specified. Nothing proved the spec
was aimed at the right targets. Replaying the actual 90d census of the
opencode-mobile Sentry project (648 events, 11 issues) through the gate's own
precedence shows 96.9% hard-dropped as transport noise and every observed crash
class (OOM, ANR, IllegalStateException) still allowlisted -> ~87 events/month
against a 1,500/month target.
Also records two findings from measuring the org directly:
* The error quota resets on the 4th. The 5,000-event month opened 2026-08-04
and was spent by 08-08; the org has accepted zero errors since. 2026-09-04 is
the date the gate has to hold by, and it is why 'submitted' is the metric.
* Server-side levers are unavailable on this plan. A per-key rate limit PUT
returns HTTP 200 and silently discards the value (verified for three window
sizes), custom inbound filters are absent, spike protection 403s. The client
gate is the only control that exists, so its coverage is the whole margin.
Refs AGE-105
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* tools(sentry): split client_discard by reason so gate drops aren't confused with quota backoff
Raw client_discard cannot show whether the noise gate works. Today 100% of
opencode-mobile's client_discard is ratelimit_backoff -- the SDK backing off a
429 because the ORG is over quota -- which rises when things get WORSE. Gate
drops land in a different reason: @sentry/core records before_send when
beforeSend returns null.
- stats_v2 now groups by reason as well as project/outcome
- the before_send vs ratelimit_backoff split always prints; --by-reason adds
the full per-project reason table
- before_send > 0 is install-share-independent, so it proves the gate is live
on real devices days before a monthly rate can bend
- documents that release-level segmentation is impossible while over quota:
rate_limited events are never stored, so release tags stop (last value
0.4.12, 2026-08-08). Version share comes from Play, not Sentry.
* ci(sentry): block a Play release whose bundle lost the noise gate
The AGE-105 quota fix is entirely client-side (every server-side lever on
this plan is dead), so the gate being *in the shipped binary* is the whole
safety margin. That is also the one thing Sentry cannot tell us: while the
org is over quota nothing is stored, release tags stop dead at 0.4.12, and
a release:0.4.14 query returns empty in a way that reads like success.
Grep the Hermes bundle inside the AAB instead, before the Play upload step:
the gate's reason codes, the transport drop-list regex, the
noise.dropped_since_last tag only applyNoiseGate() writes, and a baked-in
DSN (a release built without EXPO_PUBLIC_SENTRY_DSN makes Sentry a silent
no-op). Verified to discriminate on real artifacts - the v0.4.14 build now
on Play production passes, pre-gate v0.4.13 fails all six markers.
Also records the rejected alternative: persisting gate state across cold
starts pays off only under ~94 active devices (2,633 session envelopes/7d
vs a 6h cooldown), and the install base is above that.
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Tag pushes only reach the internal track; production is a separate manual
workflow_dispatch that rebuilds and gets its own versionCode. Document that
path and start a release-history table so the AGE-105 Sentry measurement
window has a real rollout date to anchor on.
v0.4.14: internal versionCode 150 (run 50), production versionCode 151
(run 51), submitted to the production track 2026-08-14 14:22 UTC.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
AGE-107. The 498 `API Error: 401` events from one device were not a client
token-refresh loop. Sentry breadcrumbs on the surviving events show a `touch`
event immediately before every capture, at irregular human-paced intervals
(87s, 199s, 69s, 5s, 61s, 66s) — a person re-tapping Connect, not a backoff
timer. The app's automated loops were already correct: events.ts terminates
the SSE reconnect loop on ApiAuthError (issue #76).
What actually drove it: in v0.4.4 the connection probe counted any HTTP
response as a successful health check, so a 401 was classified `ok` and shown
to the user as "Health endpoint responded — connection actually works now"
while their password was wrong. The user retried for two months. `requireOk`
(#114, v0.4.8) stopped the false success, but 401 then fell into the generic
`health-failed` bucket — "Likely wrong path, auth, or an old server version" —
which still doesn't tell anyone to fix their password.
- New `auth-failed` classification: a 401/403 from /global/health means the
server is up and reachable and rejected the credentials. Its summary names
the status, points at the password and OPENCODE_SERVER_USERNAME, and says
the server is fine. It flows straight into the existing failure Alert on
both the add and edit connection screens — which is where the password
field is, i.e. the re-auth prompt.
- It short-circuits before the root/internet probes can downgrade it: a 401
already proves the server answered.
- `health-failed` copy no longer blames auth.
- `connect auth-failed` joins the noise-gate drop-list. A wrong password is
user config, unactionable server-side, already visible in the UI and
already trended in PostHog as connection_failed{error_class:"unauthorized"}.
`health-failed` and `tls-error` still report.
Tests: 6 new (401/403 -> auth-failed, message content, root-unreachable does
not override, 404/500/502 stay health-failed, health-failed copy drops "auth",
noise gate drops `connect auth-failed` but not a raw `API Error: 401`).
263 pass, tsc --noEmit clean.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Ships 7c8bc7d (#169). Until this release, the filter exists only in main: every
installed build still uploads `connect timeout` / `connect server-unreachable`
and un-deduped retry loops, which is what makes opencode-mobile the org's #1
Sentry volume source (~4,500 events/month against a 3,500/month org quota).
User-visible change is deliberately small — quieter crash reporting, real
crashes unaffected — so the Play changelog says exactly that.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
opencode-mobile is now the org's #1 Sentry volume source (~4,500 events/mo
against a 3,500/mo org quota, AGE-105). ~1,100 of those events are three
non-defects: `connect timeout` (462), `connect server-unreachable` (157), and
one device's `API Error: 401` token-refresh loop firing 498 times.
Adds a pure, unit-tested noise gate (src/lib/sentry-noise.ts) wired into
`beforeSend`, applying three layers cheapest-first:
1. Always-send allowlist — OOM/ANR/native/fatal crash classes bypass every
limit. Quota is worthless if it silences real crashes.
2. Transport drop-list — hard drop for client-side network conditions. Hard,
not sampled: the gate runs per-install, so "1 per device per day" would
multiply by the install base straight back into thousands per month.
3. Dedup + rate cap — 6h per-fingerprint cooldown, ≤6 new fingerprints/h,
≤10 events/h, mirroring the openclaw-box-bot shim (AGE-55).
Nothing is lost by the transport drop: those failures are already user-visible
as connection UI and already trended, PII-free, as the PostHog
`connection_failed{error_class}` event. captureDiagnostic() also short-circuits
for those classifications so the event is never even built. Drops are auditable
— the count since the last delivered event rides along as a
`noise.dropped_since_last` tag.
Replaying the observed 1,126-event hour through the gate yields 5 delivered
events (1 auth report + 4 real OOMs).
Tests: 18 new, 257 total passing; tsc --noEmit clean.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* docs(waitlist): make the AGE-100 after-number a grep, not an inbox crawl
The doc told a future reader to split recovered mailto signups "by the App:
line", which in practice meant opening Chatwoot conversations one by one and
eyeballing bodies — slow, and easy to get wrong in the direction that matters
(missing a stamped mail reads as "clean").
The reconciler now does the split itself
(VibeBrowserProductPage#237) and prints it:
scanned=N synced=N skipped=N failed=N unstamped=N stamped=N builds=vX:N
Document that line as the read, with the exact gh commands, and state the pass
condition explicitly: stamped must be 0, because stamped>0 means a build that
carries the AGE-87 retry queue still fell through to mailto.
Refs AGE-100.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* docs(waitlist): record the first post-release reading of the build split
Proof the measurement pipeline works against the real inbox, not just a stub:
the first reconciler run carrying the split (2026-08-14 09:57 UTC) printed
scanned=34 synced=0 skipped=34 failed=0 unstamped=0 stamped=0
— the same 34 known conversations as the pre-release baseline. Labelled as one
hour of exposure rather than a verdict, so nobody mistakes it for the week-out
number that actually closes AGE-100.
Refs AGE-100.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
The publish workflow overwrites versionCode with github.run_number + 100, so
the gradle number (40) is not what Play reports. Production dispatch run 49 ->
versionCode 149. Without this row, play-version-share.mjs reports the new
release as an unknown versionCode and the AGE-100 after-number cannot be split
by build.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* chore(release): v0.4.13 (versionCode 40) — waitlist retry queue reaches users
Ships 2f81d34 (#165): failed waitlist signups are persisted on-device and
retried on app foreground instead of silently falling back to mailto.
Until this Play release, no user is running that fix.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(waitlist): stamp the app version into the mailto escape hatch
AGE-100 asks for the post-release mailto count "split by app version where the
mail body allows it". It did not allow it: the body was "Sign me up!\n\nEmail: x"
and nothing else, so a mail from an unreachable pre-v0.4.8 sideload is byte-identical
to one from a current build whose retry queue leaked. Those two readings have
opposite meanings — the first is the known permanent cohort, the second is a defect.
Now the escape hatch appends "App: OpenCode Mobile v<version>" (app.json, same
source Sentry uses). Absence of the line == pre-v0.4.13 build. waitlist.ts stays
free of react-native/JSON imports; the screen injects the version.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
A signup that hit a network error, the 8s timeout or a 5xx was handed straight
to a `mailto:` composer. That path is lossy by design: it only works if the user
actually presses send, and if we keep reconciling the support inbox into Brevo
list 4 forever (AGE-61's hourly job). 20 of 21 signups were lost that way before
that reconciler existed, and Play's active base is ~100% on v0.4.10+ — so this
was current builds leaking, not just the ~436 stale sideloads.
Now:
- Failed-but-retryable signups are persisted on-device
(`opencode.waitlist.pending.v1`, AsyncStorage) and retried on every app
foreground (`app/_layout.tsx`) and on the Add Connection screen mount.
- 4xx stays non-retryable: the server will never accept that address, so we ask
the user to fix it instead of queueing garbage forever.
- `mailto:` is now only ever opened by an explicit user tap ("Still not working?
Email us instead"), shown after 3 failed attempts, or offered in an alert when
device storage itself refuses the write — never as the silent default.
- The UI tells the truth: "Saved on this device — we'll finish signing you up as
soon as you're back online" instead of implying it was sent.
- `WaitlistResult.fallback` -> `retryable`, `shouldFallbackToMailto` ->
`isRetryableFailure`: the decision is about retry, not about mail.
Queue policy: dedupe by email, cap 5 entries, 30-day TTL, corrupt/foreign JSON
is discarded rather than replayed. Storage and the clock are injected so the
whole thing runs under `node --test` (16 new tests, incl. the acceptance case:
offline signup -> queued -> reconnect -> reaches the server, no mail client).
Also commits the AGE-61 measurement artifacts that were only ever local
(`distribution/waitlist-signup-path-coverage.md`, `scripts/play-version-share.mjs`)
and updates the doc's "current builds still leak" section, which this fixes.
Refs AGE-87, AGE-61.
Co-authored-by: engineer <engineer@macbookpro.lan>
AGE-61 asked for a number: what share of installs is still on a build older
than v0.4.8, where the ONLY waitlist path is a mailto: to a human inbox that
nothing reconciles back into the store (20 of 21 signups lost, 2026-08-03 →
2026-08-13). Answering it by hand is how it stays unanswered next quarter, so
this is a script, not a screenshot.
- scripts/play-version-share.mjs: Play Developer Reporting API
(crashRateMetricSet -> distinctUsers by versionCode) for the auto-updating
channel, plus --github for lifetime release-APK downloads per tag, which is
the only per-version signal the sideload channel emits. Mints its own token
from the service account we already ship to CI; no new deps, no new secret.
Play versionCodes are run_number+100, NOT the gradle ones — the mapping is
derived from the publish runs and documented inline (139 = v0.4.8).
- distribution/waitlist-signup-path-coverage.md: the measured answer.
The answer, 2026-08-14: Play is 0% stale (single reported versionCode 142 =
v0.4.10, ~90-100 daily users); the sideload channel is 25.9% stale (436 of
1682 lifetime APK downloads predate v0.4.8) and can never auto-update. There
is no iOS listing and no IzzyOnDroid presence, so nothing else contributes.
Two consequences worth stating plainly: the hourly mailto reconciler is
permanent infrastructure, not a stopgap; and shipping updates does NOT close
the leak, because on current builds the fallback still fires on timeout/5xx/
offline (src/lib/waitlist.ts:shouldFallbackToMailto).
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
An unsolicited scanner reported CRITICAL "LLM output written to a
persistent memory store" findings against src/lib/notifications.ts:145,
src/lib/sdk.ts:392 and src/lib/session-grouping.ts:24. All three are
false positives: the cited lines are an in-memory notification dedupe
Map, a URLSearchParams limit param, and a bucket push inside a pure
grouping helper. The app persists nothing model-derived — sessions and
messages live on the server and are held in memory by the stores.
Two gates so that stays true and so the one class of report that WAS
real for us (credentials in git history) gets caught before a push:
- security-scan.yml: gitleaks on push/PR to main, full history fetch.
- persisted-keys.test.ts: enumerates every SecureStore write by key.
A new persistence sink fails the suite until someone adds the key
with a note saying what it holds — which is the moment to notice if
it's model output rather than user config. Verified it trips by
adding a throwaway "cache the assistant reply" write.
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* growth: qualify 8 founding-member leads + draft outreach; park Stripe test key as founder-gated
Leads qualified from real repo engagement (issues, PRs, forks, stars), ranked
by purchase intent, each with contact channel and fit rationale. Outreach
message drafted with per-lead hooks and send rules.
Blocked on: Bitwarden Secure Note STRIPE_TEST_SECRET_KEY (folder opencode-mobile).
Vault only has OpenClawBot - STRIPE_SECRET_KEY which is sk_live_ and off-limits.
* docs(memory): log founding-member qualification and Stripe blocker
---------
Co-authored-by: Den <vibeteaichnologies@gmail.com>
Root cause: selectSession() re-runs on every navigation focus (#121's
resync), forcing isLoading back to true even when re-selecting the
session already shown on screen. That hides the whole conversation
(messages + composer) behind a spinner for as long as the redundant
GET takes -- while live SSE message/part updates keep flowing to the
store the entire time, just invisible behind the spinner. If that
GET is slow or stalls, the screen looks permanently "loading"; leaving
and re-entering only "fixes" it because it's a fresh retry, not
because anything was actually resolved.
Fix: only force isLoading=true for a genuinely cold load (no session
shown yet, or switching to a different one) via isColdSessionLoad().
A same-session re-focus refreshes in the background without hiding
existing (and live-updating) content. As a second safety net, any
live message.updated/message.part.updated/session.updated event for
the active session now clears isLoading unconditionally via
isLiveEventForSession() -- proof-of-life that unsticks the spinner
even if the GET itself never resolves.
Both are pure, unit-tested in src/lib/session-load-reconcile.ts.
Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The session/chat screen's KeyboardAvoidingView used behavior={undefined}
on Android, relying entirely on the native android:windowSoftInputMode
adjustResize (set in AndroidManifest.xml) to shrink the window and push
the agent/model toolbar + composer above the keyboard.
Since the app adopted Expo's mandatory edge-to-edge display, Android no
longer resizes the window when the keyboard opens (the system assumes
insets are handled dynamically), so adjustResize became a no-op —
leaving the toolbar and input completely hidden behind the keyboard.
Switch to behavior="padding" on both platforms so KeyboardAvoidingView
pushes the composer up using its own JS-measured keyboard height,
independent of native window resize.
Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(compliance): disclose email collection in Play Data Safety + align privacy docs (closes#143)
Google Play rejected cc.agentlabs.opencode (2026-07-22) because the Data
Safety declaration did not disclose collection of Email Address. Root
cause: the optional "OpenCode Connect" waitlist card on the Connect
screen (app/connection/add.tsx -> src/lib/waitlist.ts) collects an email
and forwards it to Brevo (email marketing/CRM) via the beta-signup
backend.
Audited all other PII surfaces and confirmed no other undisclosed
collection: Chatwoot support reports stay anonymous (no email/name),
Sentry strips URLs/tokens and sends no default PII, and PostHog
analytics uses only a random anonymous ID with coarse event properties.
Updates:
- distribution/play-listing.md: Data Safety table now declares
Personal info / Email address (collected, shared with Brevo,
optional, purpose account management); embedded privacy-policy draft
and app description updated to match.
- distribution/privacy-policy.md/.html + docs/privacy/index.html: new
section 3c discloses the waitlist email collection, third-party
services list adds Brevo, retention/rights sections and the Apple
Privacy Nutrition Label table updated accordingly.
- docs/playstore.md: checklist entry documents the rejection and points
to the fix.
- PUBLISHING.md: adds exact Play Console resubmission steps (Data
types -> Personal info -> Email address -> collected/shared/purpose)
plus a note on the earlier unrelated "Missing sign-in details" App
access blocker in case it resurfaces.
No app code changed; npm test (209 pass) and tsc --noEmit are clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ci): run required checks on docs-only PRs (unblock branch protection)
ios-ci.yml (which emits the required 'Typecheck and unit tests' check) had
paths-ignore for docs/**, docs-site/**, distribution/**, **/*.md. A required
status check that is path-filtered never runs on docs-only PRs, so those PRs
sit permanently in mergeStateStatus=BLOCKED (missing required check). Remove the
paths-ignore so required checks always run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: test <test@test.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
session.list() now fetches GET /experimental/session (all sessions across
every directory) and falls back to the legacy directory-scoped GET /session
only on 404 (older servers). A directory-less /session is directory-scoped and
returns [] when the active dir has no sessions, so the Recent Sessions list was
empty unless the user first picked a folder.
Global shaping (roots filter, title search, sort by time.updated desc, limit)
moved to a pure, unit-tested src/lib/session-list.ts (no expo/fetch import) with
10 node --test cases.
Co-authored-by: dzianisv <engineer@gray-knight-m1.local>
* fix(ci): classify unresolved workflow failures
Replaces rolling historical failure escalation with active consecutive streaks and verifies Sentry zero/unavailable states.\n\nPlan: https://github.com/dzianisv/opencode-mobile/issues/139#issuecomment-5044502048
* fix(ci): scope failure streaks to default branch
Prevents pull-request failures from becoming production health signals for issue #139.
---------
Co-authored-by: engineer <engineer@gray-knight-m1.local>
Four bugs from a review of session creation, the sessions list, and settings
(lower-severity than the core-path hunts — the core is now well-hardened):
1. Double-tap on the new-session FAB / 'Use this folder' created duplicate
sessions (isCreating state lags a render). Added a synchronous re-entrancy
ref guard.
2. 'Require biometric for messages' got stuck ON and enforced with no UI escape
after turning off the parent 'Require biometric to open' toggle (the child
switch is then disabled). authenticateForMessage now also gates on the parent.
3. Session-create failure on the default path silently closed the modal with no
feedback (only the dir path alerted). Both paths now alert; message made generic.
4. Recent-directories got duplicate entries ('/x' vs '/x/') and a mismatched
'current directory' highlight. switchDirectory/addRecentDirectory now
stripTrailingSlash.
typecheck clean, 199 tests, i18n parity.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
- MessageBubble: memo comparator only checked the last part's `.text`,
which is always undefined for tool parts, so tool-state/token/cost
updates never re-rendered when a tool part was last. Replace with a
full reference-equality sweep over message + all parts (the store
always replaces changed refs, so this catches every real change).
- DiffView: computeDiff's O(a.length*b.length) LCS table was unbounded,
risking OOM/ANR on large diffs, and the rendered line list was
unbounded too. Add a size-guarded fallback (simple truncated
remove/add diff) and cap the normal path's rendered lines, both with
a truncation marker. Extracted computeDiff into a plain
diff-compute.ts module (mirrors src/lib/scroll-config.ts) so it's
unit-testable with node:test, which can't render .tsx components.
- DiffView: normalize line endings (\r?\n) before diffing so a CRLF
vs LF mismatch doesn't show a whole file as changed.
- Markdown: the module-scope singleton CustomRenderer's github-slugger
never reset, so useMarkdown's keys climbed on every streamed token,
remounting the whole subtree. Scope the renderer per `children` via
useMemo instead.
- Markdown: theme objects used heading1/heading2/heading3/listItem,
but react-native-marked's MarkedStyles expects h1/h2/h3/li, so the
custom heading/list styling was silently dropped. Rename the keys in
both themes.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1. buildRequestHeaders: UTF-8-encode Basic-auth credentials before btoa()
so non-ASCII usernames/passwords don't throw (Hermes' btoa is Latin1-only
and the throw was an unhandled rejection that hung the connect spinner).
2. diagnostics classify(): check root.ok (server reachable) before
!internet.ok, so a reachable-but-failing server (e.g. wrong auth) is no
longer misdiagnosed as "no internet" just because the public-internet
probe also failed (captive portal, Tailscale-only network, etc).
3. sdk.ts createClient: strip trailing slashes from baseUrl once, so a
trailing-slash URL from Advanced mode / Edit screen doesn't produce a
double slash on every request path.
4. add.tsx / [id].tsx: wrap addConnection/updateConnection in try/catch so
a SecureStore failure after a successful test resets the spinner and
shows an alert instead of hanging forever. Adds
connection.shared.alerts.saveFailedTitle/saveFailedMessage (en + zh-Hans).
5. add.tsx / [id].tsx: build the diagnostics probe's auth with buildAuth()
instead of a hand-rolled expression, so the probe reproduces the real
request's credentials (previously Quick Connect's password-only case
sent no auth to the probe at all).
6. add.tsx handleQuickConnect: stop sending the shared `username` state,
which could carry a stray value typed earlier in Advanced mode and
silently override the "opencode" default after "Back to Quick".
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Seven correctness bugs in the session composer:
- sessions.ts sendMessage: await the prompt submission and rethrow on
failure instead of a fire-and-forget .catch(), so handleSend's existing
restore-draft-and-alert catch actually runs.
- pasteFromClipboard: route pasted images through toJpeg() so they get
the same resize/compress treatment as picked/captured photos.
- pickFromLibrary/pickFromCamera: wrap toJpeg() in try/catch (and switch
to Promise.allSettled for the multi-select batch) so one bad asset
doesn't silently drop the whole batch; surface a new imageFailed alert.
- pickFromLibrary: cap selection at 10 images.
- useSpeech: abort the native recognition session on unmount so the mic
doesn't stay hot after leaving the screen.
- Surface useSpeech's error via Alert, keyed on the error value so it
fires once per distinct error.
- Undo on the revert banner now also clears the composer, since it was
prefilled by the edit flow and could otherwise be sent as a duplicate.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add two new docs-site landing pages targeting search intent not covered
by existing pages:
- try-demo/ — "try an AI coding agent with no setup / no server", built
around the real offline demo mode (scripted bug-fix walkthrough: login
button + keyboard bug, reasoning, grep search, diff, permission prompt).
Closes the gap between "curious about the app" and "willing to spin up
a server" — zero existing pages address the no-setup demo path.
- gpt-gemini-android/ — "run GPT / ChatGPT / Gemini coding agent on
Android", mirroring the existing claude-code-android page for the two
other major providers opencode supports. Claude has its own page;
GPT and Gemini did not.
Both pages match the existing docs-site template exactly (same CSS,
header/footer nav, OG/meta/JSON-LD pattern, favicon, canonical URL) and
are added to sitemap.xml. Ships live on the next `deploy-docs.sh` run.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Play publish workflow uses distribution/whatsnew/whatsnew-en-US (its
whatsNewDirectory), NOT the fastlane changelogs/*.txt (those feed F-Droid).
That file was stale at v0.4.7 — so 0.4.8/0.4.9/0.4.10 all shipped to the
internal track with outdated release notes. Updated it to 0.4.10 (<500 chars).
Also corrected PUBLISHING.md, which I'd previously written wrong: (a) app.json
android.versionCode is overridden by CI (github.run_number+100), so 0.4.10's
real Play versionCode is 142, not the app.json value — hand-bumping it is
pointless for Play; (b) Play release notes live in distribution/whatsnew, not
fastlane changelogs. Discovered while promoting 0.4.10 (the Console showed
versionCode 142, not the app.json 37).
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The demo's only exit CTA was 'Connect your own server' — useless for the
majority of installers who have no server (the exact churn/retention segment).
Adds a secondary CTA pointing them to the OpenCode Connect (hosted, no-setup)
waitlist, which is the monetization funnel per the founder strategy. Additive,
reuses the existing waitlist on /connection/add and the demo's exit-tracking;
i18n en+zh in parity.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Supersedes v0.4.9 (versionCode 36, on internal but lacking these) so a SECURE,
current build is available in the Play library to promote to production:
- #124 reconnect resync (stuck 'processing' after network drop)
- #125 HIGH: biometric app-lock re-locks on background (was bypassable after
first unlock); connection password edits now persist
plus everything in v0.4.9 (demo mode, first-run clarity, core + notification
fixes). Promote versionCode 37 to production.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two issues from a security review of the credential/auth path (the review also
verified the fundamentals are solid — passwords in SecureStore, Sentry/analytics/
Chatwoot all scrub secrets).
1. HIGH: biometric app-lock never re-armed. authenticate() sets isAuthenticated
=true once at cold start and lock() was never called (no AppState listener) —
so 'Require Biometric to Open' was fully bypassable: after one unlock, anyone
with brief physical access could reopen a backgrounded app straight into
session history and connection details for the life of the JS process. Now an
AppState 'background' listener calls lock() when the toggle is on. Fires on
'background' only, so the biometric prompt / app switcher (transient
'inactive') don't cause spurious re-locks.
2. Editing a connection's password did nothing: the edit screen's password field
was never passed to updateConnection, which never wrote PASSWORDS_PREFIX — so
a user rotating a server password silently kept using the old one. updateConnection
now takes an optional password and writes it to SecureStore (blank = keep
existing, since the field loads empty).
typecheck clean, 187/187 tests.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Supersedes v0.4.8 (still on internal, not promoted) with everything merged
since: offline demo mode + first-run clarity (0.4.8), core session fixes
(#120: queued-message ghosting, selectSession race), and notification/
permission fixes (#121: permission never requested, wrong-session-on-back-nav,
misleading completion pushes, question double-reply). Production is on 0.4.5,
so promoting v0.4.9 gets users the full hardened app in one step.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Five correctness bugs from an adversarial review of the notification and
permission/approval paths (each verified against the code):
1. Notifications never worked for most users (HIGH): OS permission was only
requested when a user manually toggled a Settings switch off→on. Since
categories default on, that path never fired, permission stayed
'undetermined', and send() silently no-op'd every notification. Now request
it once on first live connection (in-context). (app/_layout.tsx)
2. Wrong-session data after back-navigation (HIGH): session screen reads a
global store and its resync ran only on mount; the native stack keeps
screens mounted underneath a pushed one, so returning to a session could
show another session's messages and permission prompts — approving the wrong
session's tool call. Re-select on focus via useFocusEffect. (app/session/[id].tsx)
3. 'Task completed' fired on aborted/errored runs (misleading, and a duplicate
push alongside 'Session error'). Gate the notify by !aborted && !errored.
(src/stores/events.ts)
4. Tapping a connection-drop notification (no sessionId) navigated to an empty
'/session/' dead-end. Route to home instead. (app/_layout.tsx)
5. Double-tap on a single-select question sent two replies; the second hit an
already-resolved request and popped a spurious 'Reply failed' alert. One-shot
guard on reply/reject. (src/components/chat/QuestionPrompt.tsx)
Verified but intentionally NOT changed: 'completed' notifications default off
(a defensible anti-spam choice — the app still notifies when the agent needs
input). typecheck clean, 187/187 tests.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Two real bugs found in an adversarial review of the core real-time path:
1. Queued message vanishes (high impact): the message.updated handler dropped
EVERY temp- optimistic message when any real message arrived, so sending a
second message while the first was still processing made the second
disappear from the chat until its own event landed ('did my message send?').
Extracted the merge into a tested pure helper (mergeIncomingMessage) that
resolves only the oldest pending temp of the same role.
2. selectSession race: rapidly switching sessions on a flaky network could let
a slow fetch for a previous session overwrite currentSession/messages of the
newer selection. Added a monotonic sequence token; a stale result is
discarded.
Also reviewed but intentionally NOT changed: the SSE-reconnect-on-connection-
switch path (already handled via the [client] effect cleanup + reconnect) and
abortSession leaving 'sending' set on failure (deliberate — the run may still
be live; per its own comment).
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Reports the activation + demo funnels the app instruments (app_opened ->
connection_succeeded, demo_started -> demo_completed -> demo_exited_to_connect)
and the money metric: of users who started the offline demo, how many later
reached a working connection. Read-only HogQL.
Needs a PostHog PERSONAL API key (read scope) — the app only ships the
write-only ingest key, so funnel data can't be read without one. This makes
that the single remaining unblock: set POSTHOG_PERSONAL_API_KEY +
POSTHOG_PROJECT_ID and run it. Prints setup instructions if unset.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Captures the proven release flow (bump+changelog -> tag -> internal ->
promote) and documents that CI publishes to internal ONLY by design: the
service account lacks production scope, so a track=production workflow_dispatch
fails with 'The caller does not have permission' after building. Records both
the recommended Console promotion (add-from-library, no rebuild) and the
optional path to fully-automated prod releases (grant the SA production
permission first). Learned the hard way when v0.4.8's production dispatch
failed post-build.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
There is no auto-deploy for the docs site — gh-pages is updated manually,
which is why fixes (e.g. the opencode->opencode-ai guide correction in #113)
land on main but not on the live site. This ad-hoc process also risks wiping
the live F-Droid repo (gh-pages/fdroid/) since docs-site/ doesn't contain it.
scripts/deploy-docs.sh copies docs-site/ over gh-pages ADDITIVELY (never
--delete) and hard-aborts if fdroid/, privacy/, or .nojekyll would go missing
or the F-Droid repo index is empty. Supports --dry-run. A dry-run against the
current site shows it would ship exactly the pending changes (the guide
install-command fix + demo.gif/mp4 + updated screenshots) and touch nothing
under fdroid/.
Usage: bash scripts/deploy-docs.sh [--dry-run]
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>