Skip to content

fix(desktop-windows): treat pi-mono's agent_settled as advisory, not unknown - #8

Open
formed2forge wants to merge 44 commits into
mainfrom
fix/windows-pi-mono-agent-settled-advisory
Open

fix(desktop-windows): treat pi-mono's agent_settled as advisory, not unknown#8
formed2forge wants to merge 44 commits into
mainfrom
fix/windows-pi-mono-agent-settled-advisory

Conversation

@formed2forge

Copy link
Copy Markdown
Owner

Summary

  • The agent_settled event from pi-mono was falling through to the default branch in PiMonoAdapter.handleEvent, logging a spurious [pi-mono] unknown event type: agent_settled warning on every agent turn.
  • agent_settled is an upstream advisory; turn_end is what actually resolves/rejects the pending prompt via handleTurnEnd. Added an explicit no-op case alongside agent_start, agent_end, turn_start, etc.
  • Added a test that exercises the real JSON-string dispatch path (not just handleTurnEnd directly): confirms a prompt stays pending across agent_settled and only completes on the subsequent turn_end.

Test plan

  • pnpm test in desktop/windows passes (new test in piMono.test.ts)
  • No [pi-mono] unknown event type: agent_settled warnings in the main-process log during a coding-agent session

🤖 Generated with Claude Code

https://claude.ai/code/session_01XCp5LUrL4FLcaLUkdDg49p

github-actions Bot and others added 30 commits August 21, 2026 16:31
* fix(desktop): ground legacy home in page glass lane

ShellWindowChrome intentionally leaves the window clear, but the legacy DashboardPage was still classified as owning its own panels. When useLegacyHomeDesign selected that route, no PageGlassLane was mounted and the window exposed the clear shell. Pass the flag into the lane policy so legacy DashboardPage receives the shared panel ground while the modern query-shell Home keeps its existing self-owned panels.

Verification: focused PageGlassLane, glass, shell chrome, and chat-first tests passed; debug build, SwiftLint, desktop test-quality, targeted Swift-format, and make preflight passed. The full Desktop suite had one unrelated flaky RewindCaptureExclusionGenerationTests failure whose isolated rerun passed.

Failure-Class: none

* fix(desktop): restore reachable Home presentations

Route the legacy preference to the redesigned hub and add an explicit oldest Home theme. Pass the resolved glass ownership decision into PageGlassLane, lift the window maximum to the visible display, and preserve the new QueryShell glass lane.

Failure-Class: FC-transparent-top-level-window-occlusion

* style(desktop): auto-format ConversationDisplayStateTests after rebase

---------

Co-authored-by: Max Carter 祁明思 <undivisible@users.noreply.github.com>
)

* Make automatic development backend deploys converge

Automatic development deploys succeeded 8 times in the 25 runs before this
change. The 13 failures had three causes, and this addresses the two that
are defects rather than configuration.

Admission required the Release Eligibility proof SHA to still equal main's
tip. Anything merging while eligibility ran therefore rejected a merged,
reviewed commit -- 8 of the 13 failures, and why development sat a day
behind main. The property that protects the runtime is that the commit is
merged, so require ancestry instead. Production is untouched: it deploys
only by explicit dispatch, which already required ancestor-of-main rather
than tip-equality.

Development also had no automatic Firestore migration path. An automatic
index reconciliation resolves its environment to prod, and only a manual
dispatch ever targeted development, so a merged manifest addition left
development with a schema that no longer matched main and every deploy
failed its readiness gate until somebody noticed -- 2 more failures, most
recently the hourly_usage (year, month) index from BasedHardware#11979. Composite
reconciliation is create-only and development carries no required
reviewer, so it now converges on the same merge that queues the production
migration. Production's approval gate is unchanged.

The manual development lane failed separately, at custom_token_signing:
its candidate audio gate authenticates against production Firebase, which
a development deploy identity cannot sign a custom token for. The probe
can now be told which account to sign as. Left unset the behaviour is
identical, so this is inert until FIREBASE_PROBE_SIGNER_SERVICE_ACCOUNT is
set and the deploy identity is granted token-creator on it.

Not addressed here: 3 failures came from GCP_FIRESTORE_READONLY_CREDENTIALS
being intermittently unavailable in the development environment. That
credential is a deliberate privilege boundary -- readiness executes admitted
source and must not hold deploy credentials -- so it wants a configuration
fix, not a code change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Update release-vector contract for the development index lane

The static migration contract counted --provision-missing across the whole
workflow, which asserted 'only one lane applies indexes'. There are now two,
one per environment, so count per job instead and pin the development lane to
its own environment, concurrency group, and push-only trigger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Resolve the newest proven main source instead of the triggering one

gpt-5.6-sol's review found the previous approach incomplete in two ways, and
both are real.

Ancestry alone was not safe. Tip-equality was doing more than proving merge
status -- it was also a currentness fence. Accepting any ancestor of main lets
a late-scheduled run deploy older code than development already had, because
Actions concurrency groups are not FIFO, and lets a run for a commit that has
since been reverted redeploy the pre-revert tree. This runtime shares
production Firestore, Firebase auth, and Stripe, so that is not benign.

Ancestry alone was also not sufficient. The scope job green-no-ops any
triggering SHA that main has moved past, before it ever inspects changed
paths. So a backend commit still never deploys if an unrelated commit merges
before scope runs: the backend commit no-ops for being behind, the unrelated
commit no-ops on its own diff. The regression test claimed to cover this but
built a later main SHA and never passed it to scope, so scope saw the backend
commit as main's tip and the assertion proved nothing. Passing it reproduces
the strand.

Both follow from deploying the triggering commit. Admission now resolves the
newest commit on main carrying a first-attempt successful Release Eligibility
proof and reachable from current main, and deploys that. Concurrent runs
converge on one target rather than racing, a revert is never undone by a late
run for the commit it reverted, and a behind trigger still deploys because the
target moves forward instead of the run being skipped. Scope's supersession
decision is removed as now-redundant, which also deletes its two GitHub API
proofs and their fixture -- the contract gains tripwires against
reintroducing it.

--trigger-is-ancestor-of-sha keeps the resolved target at least as new as the
proof that triggered the run, so a stale listing cannot move development
backwards from its own trigger. The proof listing is fetched with curl --fail
and no error suppression: an unreadable listing refuses to deploy.

The guard checkout assertion is gone rather than re-checked-out. sol was right
that it had become true by construction and added no independent evidence,
and the re-checkout it needed also made an in-flight run execute a newer
guard script than the workflow that invoked it.

Readiness now needs actions:read to list proofs. The manual lane's readiness
job already had exactly that for exactly this lookup, so the contract now
expects it for both rather than treating the automatic lane as more
restricted.

sol's P0 -- that the new development index lane writes to production -- does
not hold: RUNTIME_GCP_PROJECT_ID is based-hardware-dev in the development
environment and based-hardware in prod, so the two jobs target different
projects and cannot race on the same index. It read the value from
runtime_env.yaml's runtime_gcp_project rather than the deployed variable.
That inconsistency between the checked-in contract and the deployed value is
real and worth its own look, but it is not this lane writing to production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…sedHardware#12080)

Automatic development deploys have failed since the Managed Prometheus
sidecar landed in BasedHardware#11998:

    preflight-cloud-run-deploy.py: error: Legacy public-binding migration
    requires exactly one container per Cloud Run service

The sidecar is attached to the candidate revision after the deploy, so the
service carries two containers from then on. `_single_container` counted
containers to prove there was one unambiguous set of runtime bindings to
read, and the collector -- which holds none of those bindings -- broke that
count. Select the application container by excluding the sidecar by name
instead, which is the same rule `attach_cloud_run_gmp_sidecar.py` already
applies when it resolves the ingress container.

This is not development-only. `check_runtime_bindings` is gated to the
auto-dev profile, but `migrate_legacy_public_bindings` runs unconditionally
in `deploy-backend-stack`, and the sidecar attach is likewise ungated. The
first production deploy would attach the sidecar and succeed; every
production deploy after it would fail this argument check. Production has
one container today (`backend-1`), so the fix lands before the trap arms
rather than after.

Tests cover both call sites and both directions: a service carrying the
collector passes, and a service with two genuine application containers is
still rejected. Both new tests fail against the current script with the
exact error seen in CI.

Co-authored-by: r <r@r>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…asedHardware#12081)

Development backend deploys still fail after BasedHardware#12080, now inside gcloud:

    ERROR: gcloud crashed (ValueError): Invalid secret path
    'projects/based-hardware-dev/secrets/cloud-run-gmp-config' in annotation

`_merge_secret_annotation` wrote the config secret into
`run.googleapis.com/secrets` using the project ID. gcloud parses that
annotation with

    ^projects/(?P<project>[0-9]{1,19})/secrets/(?P<secret>[a-zA-Z0-9-_]{1,255})...

(googlecloudsdk/command_lib/run/secrets_mapping.py), so the project segment
must be numeric. Resolve the project number and write that instead.

The shape of this is worth naming. Cloud Run accepts the attach with either
form, so nothing fails at attach time; the annotation is only re-parsed by
the *next* `gcloud run deploy` on that service. The first deploy after the
sidecar lands succeeds, and the one after it crashes on a service nobody
touched in between -- which is why this read as unrelated to BasedHardware#11998.

Production has no sidecar attached yet, so it has never written this
annotation and is not carrying the defect. It would have written it on its
first deploy and crashed on its second.

`patch_service` stays pure: `attach_sidecar` resolves the number and passes
it in, so no test needs a subprocess. A numeric project is passed through
without calling gcloud at all. The new annotation test asserts against the
gcloud regex verbatim rather than a hand-copied expectation.

Failure-Class: new

Co-authored-by: r <r@r>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…Hardware#12083)

Development backend deploys fail at the post-deploy runtime-env validator:

    ERROR [cloud_run/backend]: env MEMORY_ENABLED value mismatch: expected 'on'

The deploy sets it correctly -- the gcloud command carries MEMORY_ENABLED=on
-- and the live revision then holds 'true'. The sidecar attach rewrites it
in between.

gcloud emits YAML 1.2, where `on` is a string; production's untouched export
literally contains `value: on`. PyYAML implements YAML 1.1, where on/off/yes/no
are booleans. `attach_sidecar` safe_loads that export, patches it, and
safe_dumps it back through `services replace`, so `on` lands as the string
'true'. All three of the manifest's on/off keys are rewritten; none survive.

Nothing fails at attach time. Cloud Run stores the rewritten string, and every
consumer accepts the coerced spelling, so behaviour is unchanged. It surfaces
one step later in a validator, on a service nobody edited -- the same delayed
shape as BasedHardware#12080 and BasedHardware#12081.

Restrict the bool resolver to the YAML 1.2 core set so the round trip preserves
what gcloud wrote. safe_dump already quotes ambiguous strings, so only the read
side changes. containerPort, periodSeconds and containerConcurrency still load
as integers and real booleans still load as booleans.

Production has never attached the sidecar, so its export still says `value: on`.
Its first attach would rewrite all three flags and then fail its own gate.

The test fixture is copied verbatim from a real `--format=export` of the
production service, including the unquoted `value: on`. A hand-written fixture
would have been written in the dialect the author already assumes.

Failure-Class: new

Co-authored-by: r <r@r>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…red message reads (BasedHardware#12069)

get_app_messages and get_messages' app-scoped branch (chat.py) both filter the
messages collection by plugin_id and order by created_at descending, but
firestore_index_registry.py never declared the composite that shape needs.
Production has it only because it was created by hand at some point; a fresh
self-host deploy 400s with FailedPrecondition on GET /v1/messages.
Adds MESSAGES_BY_APP_ORDERED_QUERY to the registry (mirrors the existing
chat_sessions_current_by_app_created_at shape), regenerates
firestore.indexes.json, and adds a regression test that builds both call
sites' real query chain and asserts the composite is declared.

Failure-Class: none
…ead (BasedHardware#12070)

closeCode 1011 with no captured speech means the STT backend is down,
not a transient socket hiccup. The onboarding reconnect loop (added in
BasedHardware#11704) kept retrying it every 5s forever, repeatedly popping the
"Connection Lost" dialog with no way out besides force-quitting, even
though "Skip for now" sits right next to it in the same flow.
After 3 consecutive 1011 closes with zero user segments, give up
reconnecting and surface a distinct STT_UNAVAILABLE error so the
onboarding widget can route to the existing skip path instead.

Failure-Class: none
…n socket (BasedHardware#12071)

A single failed request to a custom STT endpoint used to be treated as fatal all the
way up: SchemaBasedSttProvider.transcribe() had no retry and a 60s timeout, and once it
threw, PurePollingSocket discarded the buffered audio and reported a fatal socket error.
CompositeTranscriptionSocket reacts to any child socket error by tearing down *both*
sockets — so one slow/dropped custom-STT request also killed the healthy raw-audio
socket and forced a full transcription-socket reconnect, producing a small transcript
gap and, during a real outage, a reconnect-storm every ~60s.
- SchemaBasedSttProvider now retries a request up to 3 times with backoff on network
  errors/5xx (not 4xx) at a 10s per-attempt timeout instead of one 60s attempt.
- The multipart request path called bare `MultipartRequest.send()`, which per
  package:http silently creates and discards a throwaway http.Client() instead of
  reusing the provider's own `_client` — it now goes through `_client.send(request)`,
  fixing that and letting the retry logic (and tests) see it.
- PurePollingSocket now requeues audio from a failed flush (capped by
  maxBufferBytes, oldest dropped first) instead of discarding it, and no longer
  reports a failed transcribe() as a fatal socket error — it keeps buffering and
  retrying locally on the next timer tick, so a transient hiccup no longer cascades
  into tearing down the whole composite socket.
- CaptureController/the recording UI can now show how long custom STT has been
  unreachable ("Offline, buffering Nm") instead of silently claiming "Listening"
  while nothing is being transcribed.

Failure-Class: none
…ng verification (BasedHardware#12072)

Starting a call showed "Failed to get call token. Verify your phone number first." for
every non-200 from POST /v1/phone/token, because getPhoneCallToken() dropped the response
body and returned null. A user whose number *is* verified — out of monthly quota, on a plan
without calling, or hitting a server error — was told to do the one thing that would not
help.
getPhoneCallToken() now returns the backend's own reason alongside the token, and the
provider shows it. Reading that reason also has to cope with detail being a map: the quota
error answers with one (check_call_access in backend/utils/phone_calls.py), and the existing
'detail as String?' cast in startVerification threw a TypeError on it, so the number-entry
screen crashed instead of explaining the quota. Both paths now go through one shared reader.

Failure-Class: none
…asedHardware#12075)

The peek-pill retract watchdog trusted screen.getCursorScreenPoint() as the
sole signal for "has the cursor visited the pill" (src/main/bar/watchdog.ts).
That API is unreliable on native Wayland (the protocol doesn't let an app
query the global pointer position outside its own focused surface), so
hasBeenHovered never armed and every peek pill retracted to opacity 0 after
the fixed ~3s linger, regardless of a real ongoing hover — the window stayed
mapped (visible to the compositor) but its content was permanently invisible.

Verified live via CDP against the running dev instance (ws://127.0.0.1:9222):
the bar's DOM was captured mounted correctly ("Listening" label + orb) but
stuck in the .bar-slide-out class (opacity: 0). Fix corroborates the OS
reading with the renderer's own mouseenter-confirmed interactivity signal
(already relayed via the existing bar:setInteractive IPC), which only ever
makes the watchdog less eager to retract. Confirmed fixed by the reporter on
real hardware (Asahi Fedora Remix aarch64 + niri): the pill now stays open and
expands to the chat surface on click.

Failure-Class: new

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…rminal (BasedHardware#12076)

isRetryableDropError only inspected the error MESSAGE, never the source
error's DOMException `name` — so a permanent getUserMedia failure (no device,
permission denied, device unreadable) fell through the same path as an
ordinary transient network drop and retried silently through the whole
~4.5min backoff budget (MAX_RECONNECT_ATTEMPTS=10), with no error ever
reaching captureLiveStore / the UI.

Reproduced live on real hardware via CDP against the running dev instance's
capture window: navigator.mediaDevices.enumerateDevices() returned zero
audioinputs and getUserMedia({audio:true}) threw NotFoundError ("Requested
device not found") — a genuinely permanent source failure (confirmed
separately as a PipeWire/asahi-audio config gap on this machine, not an Omi
bug: `wpctl status` shows no audio Source despite the raw ALSA hardware
existing). The message text alone never matched isRetryableDropError's
quota/sign-in patterns, so this exact case retried forever.

Fix: pass the error's `name` through (AudioSessionHost.ts's audio-source-error
already relays it) and treat NotFoundError/NotAllowedError/NotReadableError/
OverconstrainedError as terminal, alongside the existing quota/sign-in cases.

Note: even with this fix, a non-quota terminal error still has no UI surface —
maybeTriggerTranscriptionQuotaPopup (usageLimit.ts) only reacts to quota
messages, so captureLiveStore's 'error' status is otherwise silently dropped.
That's a separate, broader product decision (what should a generic recording
failure toast say, where does it render) — filed as a follow-up rather than
bundled here.

Failure-Class: new

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#12085)

* test(ci): cover authoritative main-push metadata

* fix(ci): use live PR body for main-push metadata

Failure-Class: none
…12086)

* test(dev-harness): cover loopback hostname lookalikes

* fix(dev-harness): reject loopback-lookalike hostnames

Failure-Class: FC-implicit-resource-selection
…dware#12087)

* test(dev-harness): cover non-executable Typesense overrides

* fix(dev-harness): reject non-executable Typesense overrides

Failure-Class: none
…rdware#12088)

* test(dev-harness): cover desktop profile env determinism

* fix(dev-harness): honor explicit desktop profile environment

Failure-Class: FC-settings-mirror-retains-stale-override
…12089)

* test(backend): cover primary-user action-item grounding

* fix(backend): ground action items on primary user

Failure-Class: none
…dHardware#12077)

canForwardRendererCaptureCommand restricted ptt-warm/release/start/drain/
dispose/rebuild to senderId === mainWindowId only. usePushToTalk.ts — the only
caller of these commands — is used exclusively by bar components (BarApp.tsx,
BarChatSurface.tsx, BarHintStrip.tsx); there is no main-window caller. The
check predates the bar (comment: "all remaining controls originate in the
main UI"), from when PTT lived in the old overlay window it replaced. Every
PTT command the bar issued was therefore silently dropped
(console.warn('[capture] rejected unauthorized command: ptt-release'), no
renderer-visible error), so holding to talk from the bar never actually
started the mic graph.

Fix: the bar window is now an authorized sender for the PTT command group
specifically. The other main-only commands (live-view, live-finalize,
screen-view, assistant-speaking, assistant-utterance, auth-changed) are
unaffected — grep confirms no bar component calls them.

Failure-Class: new

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ng (BasedHardware#12060)

`_scan_function_body` classified every `database.*` import called inside an
`async def` as a synchronous DB call, without checking whether it was awaited.
`await get_async_redis_client()` is the correct way to reach the shared async
client, and the scanner reported it as a blocking helper.

That made a correct change unpushable: `backend-async-blockers` is a blocking
pre-push gate and this scanner has no allowlist, no inline waiver, and no way to
record that a finding is wrong. The only routes past it were to restructure
working code around the linter or to bypass the gate.

Awaited calls are now skipped, keyed on AST node identity rather than line
number — one line can hold an awaited call and a synchronous one, and only the
awaited half is safe.

The rule does not widen: an unawaited `database.*` call inside an `async def` is
still reported, with a test pinning that.

Verification:
  python3 -m pytest backend/tests/unit/test_scan_async_blockers.py -> 27 passed
  (25 pre-existing + 2 new)

  Guard proven by removing it: with the awaited-call skip deleted, exactly one
  test fails — the awaited-accessor case.

Failure-Class: none
…e#12007)

* docs(backend): expose self-hosted Firebase auth project

Document the credential-free Firebase token audience used by self-hosted backends and separate it from the operator data project (BasedHardware#6636).\n\nFailure-Class: none

* docs(backend): clarify Firebase project fallback
…rdware#11896)

Integration nudges recognize a site from the browser's window title. The rule
was "the title ends with the site's name", and both halves of it were wrong
against real titles.

Measured against 95,577 titles from a developer's own Chrome and Arc history:

  site      visits   before    after
  gmail      16988    45.9%    96.6%
  x           4177    91.5%    91.5%
  chatgpt     2798    75.4%    75.3%
  claude       257    91.1%    91.1%
  gemini       220    99.5%    99.5%

Gmail is the miss that matters. On a Google Workspace domain the mailbox is
titled after the organization -- "Inbox (3,012) - you@company.com - Acme Mail"
-- so the word "Gmail" never appears. That is 51% of the Gmail visits in the
corpus, and every one was invisible to the nudge. A new trigger recognizes it
by the account address Gmail puts in the middle segment; " Mail" alone would
claim Proton and Yahoo too.

The other half is what the rule wrongly claimed. "Ends with" reads any sentence
that trails off in a site's name as the site itself, so "How to Create Studio
Ghibli Style Art With ChatGPT" was ChatGPT, "Context for Claude" was Claude,
and "World Wealth Report 2024: HNWI Wealth Management | Capgemini" was Gemini.
The name now has to be the whole title or sit behind a separator, which drops
all three and costs no real title. A one-character name never stands alone --
"X" as an entire title is a Stripe checkout page in this corpus.

Two smaller facts fell out of the same measurement. Gemini titles itself
"Google Gemini" behind an invisible left-to-right mark, so it survived only
because the unanchored rule matched the last six letters; anchoring without
stripping the mark and naming the site correctly would have silently dropped
it to 59.5%. And the fixture that was supposed to hold real titles held two
invented ones -- "Reviewing a diff \ Claude" and "Swift concurrency question -
ChatGPT" -- neither of which occurs once in 95,577 titles. They are replaced
with observed shapes, which is what that fixture's own comment asked for.

Verification: 92 IntegrationNudge tests pass. The before/after table above was
produced by running the real matcher over the corpus through a temporary test,
on this branch and on main, and is not committed.

Failure-Class: FC-meeting-trigger-title-identity-drift

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Failure-Class: none

Tests: ./scripts/dev-feedback.py --once swift StopReconciliationTests (46 passed)

Co-authored-by: axAilotl <231548431+axAilotl@users.noreply.github.com>
…nd remove task execution (BasedHardware#11974)

* feat(tasks): make AI capture suggestion-only and expire suggestions in 2 days

Auto-generated tasks were landing directly in the user's task list through
four independent paths. On one dogfood account, 121 of 124 surviving tasks
were machine-written, and 340 of 353 accepted Candidates were accepted
within 2 seconds of creation — machine acceptance, not a human gesture.

Establish one invariant: an automatically extracted task is never written
to the task list. Every AI-derived proposal is a pending Candidate that
reaches the list only through an explicit user gesture.

Backend:
- capture_policy: drop the `auto_accept_silent` and `create_direct`
  outcomes. Self-reported model confidence now decides whether a proposal
  is worth surfacing, never whether it may bypass the user.
- conversation_capture: stop create-then-accept, and handle policy
  rejection per item. A single ignored item no longer drops the whole
  conversation onto the legacy writer.
- process_conversation: `_save_action_items` only proposes. The extracted
  items still live on `conversation.structured`, which is what the summary
  view renders.
- Candidates carry a real `expires_at` (2 days), cleared on resolution so
  accepted rows survive as the audit link. Reads treat a lapsed pending
  Candidate as expired, deriving a deadline from `created_at` for rows
  written before the field existed, so the existing backlog ages out with
  no backfill. A Firestore TTL policy reclaims the storage.
- Replaces the 14-day read-time freshness filter, which hid old
  suggestions without ever expiring them.

Desktop:
- No workflow mode routes captures onto legacy staging, whose end is
  automatic promotion. `.off` in particular is what /v1/candidates/control
  reports when its own read fails; a backend hiccup must not become
  unrequested tasks. Captures defer and retry instead.
- Screen capture policy mirrors the backend change (both read the same
  frozen fixture).
- Conversation summary: action items move directly under the summary and
  each carries an explicit "Add to Tasks".
- Tasks page keeps category grouping in multi-select; only the row's
  selection control changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(chat): replace the "Saved to Tasks" receipt with a suggested-task card

The notch/chat receipt existed to acknowledge a task Omi had already written
into the user's list. Under I1 nothing is written without the user, so the
receipt announced something that no longer happens.

Surface the proposal instead. A new pending Candidate seen while listening
posts a moment carrying the candidate id and description; chat renders it as
a native card with "Add to Tasks", which resolves the candidate through the
same accept path the Tasks page's Suggested section uses.

- SuggestedTaskChatCard encodes/parses the card payload inside the message
  text, the way BackgroundAgentSummary already does, so a card survives a
  transcript reload with no schema change.
- SuggestedTasksStore gains a shared instance: the Tasks page and the chat
  card accept from the same pending set, so accepting in one place removes
  the row from the other.
- NotchMomentsCoordinator observes suggestions rather than created tasks.
  Undo becomes a no-op: there is no longer a write to retract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tasks): remove the ability to execute a task

Tasks are a list the user keeps, not an agent surface. Both execute
affordances are gone.

The shipping one: "Execute with Omi" in the task detail panel, the inline
"Execute" pill on each row, TasksPage.investigateTask, and
TaskChatCoordinator.investigateInBackground. RecurringTaskScheduler goes
with them — its only job was firing those investigations on a timer, and
nothing ever called start().

The legacy one: the tmux/CLI Terminal Task Agent. TaskAgentManager and
TaskAgentViews are deleted, along with the startup session-restore call
and the "Terminal Task Agent" settings card — the last user-reachable
remnant of a path that had no working launch button left.

TaskAgentSettings keeps only what the surviving task chat thread uses:
isChatEnabled, the working directory, and the canonical prompt.

The dead agent* display goes too — status, plan, prompt, and edited-files
rows in the detail panel and tooltip, the .execute action in
TaskDetailPanelActionPolicy, and the agent column descriptions handed to
the chat SQL tool, which otherwise invites queries against columns nothing
writes.

The local agent* SQLite columns are deliberately left in place. They are
nullable and now unwritten, and GRDB decodes by name, so they cost nothing;
dropping them needs a migration whose downside on a live user database is
worse than the untidiness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tasks): admit only proposals the user will actually see

Extraction wrote suggestions, but two capture kinds wrote ones nobody could
read. The Suggested surface applies a 0.8 confidence floor; `direct_request`
and `inferred_next_step` already gated on it, so below the floor they were
ignored. `explicit_command` and `clear_commitment` did not, so a low-confidence
item became a pending Candidate the surface would never show — stored forever,
displayed never. That is the accumulation pattern that grew staged_tasks to
five figures, and half of it was introduced when explicit_command stopped
creating tasks and started proposing without picking up the floor.

All four kinds now clear the same floor. What the policy admits, the user
sees; anything below it is ignored rather than quietly stored. Fail-closed
stays fail-closed: extraction that omits confidence still scores below the
floor and is dropped.

test_conversation_suggestion_visibility walks policy → Candidate → suggested
projection for every capture kind, so "a Candidate exists" can no longer pass
for "the user was offered something".

Two further I1 gaps the desktop suite surfaced:
- CanonicalScreenCandidateDelivery accepted its own candidate when the outcome
  said so. Inert once the policy stopped emitting those outcomes, but `accept`
  remained on the capture client's protocol. Both are gone: the capture
  pipeline now has no way to accept anything.
- ScreenCapturePolicy had the outcome change but not the floor, so the two
  policies would have disagreed. They share one frozen fixture, which is what
  caught it.

Also repoints the `recurrence` writer class at TasksStore, which owns
recurrence rollover now that RecurringTaskScheduler is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tasks): restore a constant lost with the legacy writer, and finish the removals

Review pass over the branch (glm-5.3 via omp), findings verified independently.

The serious one: removing the legacy action-item writer also removed the
module-level TRANSCRIPT_CHUNK_INDEXING_ENABLED that happened to sit directly
after it, while leaving its use in the finalization pipeline intact. Every
conversation finalization would have raised NameError. Transcript-chunk
indexing has nothing to do with action items; the deletion was collateral.
Confirmed by reproduction: the name is defined on origin/main, absent at the
previous commit, and test_process_conversation_usage_context fails 7 there and
passes 40 here. That suite escaped the earlier sweep because its filename is
test_process_conversation_*, not test_conversation_*.

User-visible: the notch pill was the sole renderer for the suggestion moment
and drew notification.title raw, so it showed the encoded card payload —
candidate id and all — to the user, beside an Undo button wired to a
now-empty handler. It parses the card now, shows the description, and offers
Review only; there is nothing to undo because nothing was written.

The suggestion moment also lost both guards the receipt path had: ids were
never seeded and the 120s freshness gate was gone, so a store load landing
mid-conversation could announce a days-old proposal as if Omi had just made
it. Restored as a pure function with regression tests.

The e2e conversation test still asserted extracted items land in
users/{uid}/action_items — the exact write I1 forbids — and patched three
attributes the branch had deleted.

Removal fallout: updateAgentState, getActiveAgentSessions, updateAgentStartedAt,
clearAgentState and getDueRecurringTasks were orphaned by deleting
TaskAgentManager and RecurringTaskScheduler, as was
StartupWarmupPolicy.recurringTaskSchedulerInitialDelay. All had zero callers.

Two known gaps left unfixed and recorded in the tracker rather than patched
over: the 14-day candidate reuse window now outlives the 2-day suggestion TTL,
and "Add to Tasks" on a conversation summary does not resolve the twin pending
candidate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(desktop): changelog entry for suggestion-only tasks

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(tasks): drop the hand-written Firestore TTL entry

firebase_index_manifest generates fieldOverrides from FIELD_INDEXING_EXEMPTIONS
and can only emit `ttl: false` indexing exemptions. A hand-added `ttl: true`
policy is therefore not reproducible from the registry, would be wiped by the
generator, and fails the manifest contract.

Read-time expiry already delivers the behaviour: every read treats a lapsed
pending Candidate as expired. What is lost is storage reclamation, which is
recorded rather than papered over — enabling auto-deletion on a live collection
group deserves its own decision, not a line in a generated file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(desktop): cover TaskChatCoordinator in the task-thread flow

The scenario-13 proof opens the real TaskChatPanel across first, second and
resumed workstream projections; that behaviour is TaskChatCoordinator's, so
the flow already exercises it and should say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(api): regenerate the app-client OpenAPI spec for Candidate.expires_at

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(app): regenerate task-intelligence Dart wire models for expires_at

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(desktop): regenerate Swift OpenAPI types for Candidate.expires_at

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(desktop): let the flow lint see the notifications bridge actions

ai-chat-settings.yaml still covered TaskAgentViews.swift, deleted with the
execute feature.

Separately, desktop-flow-lint was already red on origin/main: it builds its
registered-action set by scanning ACTION_SOURCE_RELATIVE_PATHS, which omits
DesktopAutomationBridge+Notifications.swift, so the two actions registered
there read as unknown. Confirmed by running the lint on a clean origin/main
worktree. Unrelated to this branch, but it blocks every desktop push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(invariants): register INV-TASK-2 and guard it in CI

The rule this branch enforces by construction is now a named product
invariant with a static guard, so the next change cannot quietly undo it.

INV-TASK-2: a task the user did not ask for is never written to their task
list. Automatic capture — conversation, screen, proactive — produces a pending
Candidate that becomes an action item only through an explicit user gesture,
and an unacted Candidate expires rather than accumulating.

check_task_capture_authority.py asserts five structural facts, chosen as the
shapes that actually shipped rather than as prose matching:
- no capture-policy outcome constructs auto_accept_silent or create_direct
  (in either the Python policy or its Swift twin)
- conversation_capture never calls accept_candidate
- _save_action_items never calls an action-item writer
- CanonicalScreenCandidateClient exposes no accept(); a delivery path that can
  accept eventually gets wired to one
- no source governed by shared_capture_policy declares an action-item create
  anchor, so a new extraction writer cannot be registered without failing here

Run against origin/main the guard reports all four original write paths plus
the manifest anchor, and test_check_task_capture_authority.py pins that it
still fails on each shape. It also has to tolerate the files naming those
outcomes in comments that explain their removal, which is why the checks match
construction sites rather than words.

Status is `proposed`. The registry promotes to `locked` only after the rule and
its guards stand unchanged for seven days; earliest promotion 2026-08-27. The
behavior is CI-enforced today either way — `locked` adds the requirement to
name the ID in PR bodies touching these paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(desktop): repair two source-grep contracts broken by the bridge split

Both failures arrived with the rebase onto current main, not from this branch.
d5596a6 relocated the notification bridge actions into
DesktopAutomationBridge+Notifications.swift to satisfy the line-count ratchet,
and two consumers still grep only the original file:

- desktop-flow-lint's ACTION_SOURCE_RELATIVE_PATHS (fixed earlier on this
  branch, which is why the push gate went green)
- DesktopAutomationSecondaryActionTests.bridgeSource(), which reports
  settings_notifications_snapshot and set_notification_settings as
  unregistered although both are registered

bridgeSource now reads every DesktopAutomationBridge* source, so a future
relocation to satisfy the same ratchet cannot break it again.

Separately, the referral settings section landed on main without updating
PermissionsPagePresentationTests' expected sidebar order.

Neither reproduces in a full local `swift test` — CI runs each suite in
isolation, and the full-suite log for this branch predates the rebase. Both
reproduce in isolation and pass after these fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* fix(macos): add cloud connector disconnect state

* docs(macos): note cloud connector disconnect

* test(macos): clean cloud disconnect coverage

---------

Co-authored-by: r <r@r>
…Monitoring (BasedHardware#12099)

* fix(monitoring): accept the Cloud Run metrics egress filter at Cloud Monitoring

The dedicated Stackdriver exporter added in BasedHardware#11998 has never imported a
single series. Cloud Monitoring rejects a filter that mixes AND with OR
across resource.labels restrictions, so every descriptor query returned
HTTP 400 while the exporter stayed Available, its Prometheus target
stayed up, and Grafana showed empty panels that read as no traffic.

Express the namespace disjunction as one_of(...), which the filter
grammar defines for this case. Keep the namespace scope: dropping it
would import every omi_ series from every Cloud Run service in the
project.

Add omi-cloud-run-metrics-egress-query-rejected, alerting on the
exporter's own upstream error rather than on its liveness. Extend the
exporter contract test to cover the dev values file, which was
unasserted and is what the automatic post-merge rollout installs.
Correct the runbook's verification step, which queried the mangled
metric name that a healthy deployment no longer produces.

* fix(monitoring): key the Cloud Run observer exemption on the monitored resource

The production-data-plane-routing guard exempts the Stackdriver egress
values files from its retired-GKE-desktop-backend rule only when they
contain the literal resource.labels.namespace="desktop-backend". That
pins the exemption to one spelling of a filter rather than to what makes
the file a Cloud Run observer, so rewriting the disjunction as one_of(...)
to satisfy Cloud Monitoring's grammar made a read-only metrics reader
look like retired GKE ownership.

Key the exemption on resource.labels.cluster="__run__" instead. That is
Cloud Run's reserved pseudo-cluster, so together with the
prometheus.googleapis.com/ prefix it identifies the monitored resource
directly and survives any future edit to the namespace set.

---------

Co-authored-by: r <r@r>
…e#12098)

Failure-Class: FC-serialiser-dialect-mismatch-retypes-values

Co-authored-by: r <r@r>
github-actions Bot and others added 14 commits August 24, 2026 09:59
…asedHardware#12103)

Grafana refuses to create a rule whose UID exceeds 40 characters. Two
exported rules were over it and could never be provisioned:
omi-cloud-run-metrics-egress-query-rejected (43) and
omi-llm-gateway-invalid-request-rejections (42). Neither was live; both
returned 404 from the provisioning API.

Because the repo export is a mirror rather than the live source, an
unprovisionable rule reads as complete in review, in the README
inventory, and in every contract test. Rename both and assert the cap
across the combined export and the split sources so the next one fails
in CI instead of at the console.

Co-authored-by: r <r@r>
…asedHardware#12106)

The onboarding omi-demo.mp4 (26s) loops forever, so its audio played the
whole clip and repeated on every loop. Mute the player once playback
reaches 10s, so the sound never plays longer than 10s or repeats; the
video keeps looping silently.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The bed that plays through onboarding is pad.m4a, scheduled on AVAudioEngine
with `.loops` from one decoded buffer. Nothing in the cinematic ever calls
stopMusic, so it played for the life of the process — users heard intro music
that never ended.

PR BasedHardware#12106 aimed at the wrong player: it capped omi-demo.mp4 in OnboardingView,
which is a different AVPlayer and not what is heard here.

Two fixes:
  - OmiSoundController.maxMusicDuration = 10. startMusic schedules a fade-out at
    the cap; stopMusic cancels it so a normal stop is not double-fired.
  - The demo video's budget is now per app session (OnboardingDemoAudioAllowance)
    rather than per AVPlayer. SwiftUI rebuilds that view on every onboarding
    step, and each rebuild restarted the old boundary observer at item time zero.

Verification:
  swift test --filter "OmiOnboardingSoundTests|OnboardingDemoAudioAllowanceTests"
  19 tests, 0 failures. The cap test spends a real 10.9s against the real
  OmiSoundController before asserting the fade, rather than a mocked clock.
  Confirmed audibly by Nik on a fresh named bundle built from this change.

A log line is emitted on the cap path so a build can be checked without ears:
  grep -a "bed reached" /tmp/omi-dev-com.omi.<bundle>-*.log

Failure-Class: none
…ware#12109)

* fix(macos): repair onboarding permission and profile setup

* fix(macos): preserve notifications settings retry contract
…e#12096)

* feat(memory): measure decision-path telemetry

Add a bounded Cloud Logging and fixture-backed report for canonical memory capture and promotion decisions. Make every rate carry denominators, add per-user macro estimates, and keep applied rejection rules separate from operational retries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(memory): record how many speakers were flagged as the owner

The v1 capture record carries distinct_speaker_ids but nothing about how many of
those speakers diarization marked as the account owner, so two states it cannot
express are exactly the two that decide whether anything from a conversation can be
promoted: zero (the owner was never identified, so every memory is born third_party
and dies at the 48h TTL) and more than one (impossible by construction -- an account
has one owner -- and a direct signal that speaker clustering shattered). Neither is
derivable from distinct_speaker_ids.

On one real account, 52.9% of wearable conversations had zero owner speakers and
23.5% had more than one, against 0% and 0% for multi-channel desktop capture. That
comparison is the reason the telemetry exists, and v1 could not measure it.

Adds owner_speaker_ids to the capture record. Still an integer, still no text.
Landing it before the first deploy costs one deploy cycle instead of two.

Also fixes an order-dependent test the report suite shipped with:
test_cloud_query_is_bounded_and_truncation_fails_closed asserted --project's default
of based-hardware, but --project falls back to GOOGLE_CLOUD_PROJECT first and
test_working_observations_extractor.py sets that process-wide at import
(os.environ.setdefault("GOOGLE_CLOUD_PROJECT", "test")). It passed alone and failed
whenever that file was collected first. The test now owns the variable. Verified
present before this commit, so it was not introduced here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(memory): report owner-speaker health, not just its absence

The previous commit started emitting owner_speaker_ids but nothing read it, so the
one comparison the telemetry exists to make -- multi-channel desktop capture, where
is_user comes from the audio channel, against wearable single-mic clustering -- still
could not be run. Classify each conversation as owner_silent, single_owner, or
multi_owner and split those rates by capture regime.

A conversation whose event predates the field is reported as 'absent' and excluded
from every owner-health denominator. Folding it into owner_silent would manufacture a
diarization failure out of missing telemetry, which is the same co-occurrence-as-
causation error that produced the retracted "96% of memory loss is broken identity"
claim.

Failure-Class: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
BasedHardware#12132)

Production deploys crashed with `gcloud crashed (ValueError): Invalid secret
path 'projects/based-hardware/secrets/cloud-run-gmp-config' in annotation`.

gcloud validates run.googleapis.com/secrets against
`^projects/[0-9]{1,19}/secrets/...`, so a project ID never matches. Cloud Run's
API accepts either form and stored it, so the attach that wrote it succeeded and
the failure surfaced on the next unrelated deploy instead.

Resolving the project number stops new corruption but cannot clear a value
already persisted on a live service, which blocks every deploy until repaired.
Add `--repair-secret-annotations` (with `--dry-run`) to rewrite those paths in
place, verify by re-reading the service afterwards, and no-op when nothing needs
changing. Also normalize every entry in the merge path, not just the sidecar's
own secret, since a stale path under any name breaks the same deploy.

Failure-Class: FC-metadata-format-validated-only-on-next-read

Co-authored-by: r <r@r>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ations (BasedHardware#12134)

Repairing the secret annotation on production failed with
`ALREADY_EXISTS: Revision named 'backend-465cd0f-32620507075-1' with different
configuration already exists`.

A failed deploy leaves its pinned revision name in spec.template.metadata.name,
and `services replace` will not recreate that name with different config. That is
the same state which leaves the annotation needing repair, so the two always
co-occur and the repair path could never work on the state it exists to fix.

Drop the pin and let Cloud Run name the revision. Traffic is unaffected: the
export's traffic block still pins the serving revision, so the new one lands at
zero percent. `--dry-run` returns before `replace`, so it could not surface this.

Failure-Class: none

Co-authored-by: r <r@r>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…unknown

The event dispatch switch in PiMonoAdapter.handleEvent had no case for
agent_settled, so it fell through to the default branch and logged
"[pi-mono] unknown event type: agent_settled" — even though the adapter
already treats several other protocol-control events (agent_start,
agent_end, turn_start, etc.) as observed-but-ignored no-ops. agent_settled
is an upstream advisory event; turn_end is what actually resolves or
rejects the pending prompt via handleTurnEnd, and that's unchanged.

Added an explicit no-op case alongside the others, plus a test exercising
handleEvent's real JSON-string dispatch path (not just handleTurnEnd
directly) to confirm a prompt stays pending across agent_settled and only
completes on the subsequent turn_end — matching the equivalent coverage
desktop/macos's pi-mono adapter already has.
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.

10 participants