Commit Graph
10734 Commits
Author SHA1 Message Date
Jinwoo Hong e1bac25041 fix(cloud): diagnose wrapped relay trust probe failures (#20403) 2026-09-12 15:25:58 -04:00
Neil 403b62a8d8 ci: balance existing unit and E2E shards using recorded timings (#20367)
* ci: balance existing unit and E2E shards using recorded timings

* ci: fix timing refresh units and deferred-menu test traversal

* ci: preserve isolated E2E window launch policy

* ci: keep diagnostic artifact outages from failing tests
2026-09-12 03:10:05 -07:00
Neil b357913654 fix: keep landing footer menu above status bar (#20360) 2026-09-12 01:40:37 -07:00
Neil 487d39e02e perf(browser): fill plain fields in one stdin edit (#20364) 2026-09-12 01:36:59 -07:00
Neil 4aa9329e99 ci: narrow pnpm cache keys and shallow development checkouts (#20370)
* ci: cache dependency downloads and shallow development checkouts

* ci: defer mobile caches after measuring restore overhead

* ci: retain existing release signing cache behavior
2026-09-12 01:31:04 -07:00
Neil 1b5092492f perf: scan mobile markdown links without repeated suffix searches (#20322) 2026-09-12 01:29:14 -07:00
Neil 3bc631dad3 perf: validate mobile review table delimiters by cell (#20317) 2026-09-12 01:29:11 -07:00
Neil cd8e98fdf9 fix: prevent mobile markdown parser from stalling on unsupported blocks (#20313) 2026-09-12 01:29:09 -07:00
Neil 57b0355f72 perf: index Hermes session correlation instead of rescanning history (#20275) 2026-09-12 01:25:16 -07:00
Neil de8e5421ff ci: reuse immutable package setup across shutdown checks (#20368) 2026-09-12 01:23:05 -07:00
Neil 7b53b5abd1 test: replace fixed UI waits with observable readiness (#20369) 2026-09-12 01:19:07 -07:00
Neil 923858e098 perf: defer closed menus to speed up cold worktree switching (#20311) 2026-09-12 00:52:05 -07:00
github-actions[bot] 719c4341a2 Update README downloads badge 2026-09-12 07:01:46 +00:00
Brennan BensonandMerge Sim a05e2139d6 fix(claude): refuse a structured model the provider does not list (#19946)
* fix(claude): refuse a structured model the provider does not list

setClaudeStructuredOption applied a model to a live Claude structured
session with no check that the provider lists it, while pre-flighting
`effort` against the same catalog a few lines above. Measured on Claude
Code 2.1.260: set_model resolves for an unlisted id, list_models never
gains a row for it, and every later turn returns is_error with empty
modelUsage and zero tokens — a session that looks alive and produces
nothing. Nothing undoes the write, so the refusal has to precede it.

Two paths reach it: restore replays a stored pick the provider may since
have retired, which needs no user error at all, and any caller can send
an arbitrary id mid-session.

An absent, failed or empty list deliberately refuses nothing, mirroring
the null rule the effort guard already applies: no catalog identifies no
model, and a CLI predating list_models would otherwise have every model
refused under it — silently, since restore swallows the rejection into
restoreSkippedOptions.

* refactor(claude): keep the model pre-flight's permissive case in the authority

claudeCatalogAdmitsModel now answers the question outright instead of
handing back a nullable id set the caller had to interpret. The rule that
an unidentified catalog refuses nothing lives inside the function, so a
second caller cannot get it wrong by omission — and getting it wrong is
silent, because restore swallows the rejection into restoreSkippedOptions.

The refusal message names the model the user asked for, since it reaches
them as the chat error row.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 23:45:45 -07:00
Brennan BensonandMerge Sim ef6eeab26e fix(native-chat): shrink skill pill text (#20254)
Co-authored-by: Merge Sim <sim@local>
2026-09-11 23:13:05 -07:00
Jinwoo Hong eedd35645e feat(mobile): add typed RPC operations and fence raw requests (#20018)
* feat(mobile): add the RpcOperation descriptor, send, and barrier interpretation

An operation family declares its method, compatible reader, acceptance policy and
interpretation barrier once. The send classifies only a fulfilled envelope; transport
rejection stays on the promise channel as the original error object, so the cutover and
delivery-unknown predicates keep working and a Promise.all group still fails fast.
Multi-request families go through a post-barrier combinator that awaits every raw request
and then interprets in declared order.

No production call site is migrated: this lands as self-contained machinery so runtime
behaviour is provably untouched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): require a reader for RPC result variants

* refactor(mobile): fence the raw RPC request port behind an inventoried boundary

The raw sender takes an unchecked method string and returns an envelope whose
result is `unknown`; 153 non-test files still reach it and each re-decides
acceptance and decoding for itself. The type system cannot close that today —
`RpcClient` structurally carries `sendRequest` and ~190 files hold a client — so
move the port's declaration into its own module, name it unvalidated, and hold
the boundary as a ratcheted inventory instead.

`SendRequestOptions` is re-exported from rpc-client.ts so the move touches no
call site, and rpc-operation.ts now asks for the port rather than the whole
client: it is the one module allowed to cross it.

Two ratchets, both AST-based:
- the port inventory fails on an unlisted file, a stale entry, and a listed file
  whose reference count went up, so the list only shrinks;
- the cast fence bans `as`, `any` and `@ts-` suppressions in the operation
  region, which is computed from the imports rather than listed, so step 4's
  operation modules land inside it automatically.

Zero runtime change: no wire change, no call site touched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* merge: incorporate closed boundary and send-side types

* fix(mobile): preserve RPC decoding invariants across the combined boundary

* fix(mobile): consolidate RPC operation test imports

* refactor(mobile): simplify RPC descriptors and fence the contract module

* fix(mobile): baseline landed notification RPC callers
2026-09-12 01:50:41 -04:00
Jinwoo Hong fa3d29fa11 feat(ai-vault-search): session search query engine over the index (#20029)
* feat(ai-vault-search): give the index a generation a page cursor can be fenced by

A search page is a slice of one ranked list, so a cursor only means anything
against the snapshot that produced it. The store now keeps a monotone
generation in `meta`, bumped by every mutation that can change which rows a
read returns, and starts a new one on open so a write whose bump never landed
cannot leave a cursor pointing at content that is already gone.

Schema version 2 adds the two tables the query layer needs: `messages_vocab`
(fts5vocab over messages_fts, the typo repair's whole dictionary) and
`search_log`. Both are pure additions and the index is a cache, so a version-1
file is dropped and rebuilt exactly as any other mismatch is.

* feat(ai-vault-search): plan a query the way the index tokenized it

The planner unfolds the tokenizer contract instead of asking SQLite: same
boundaries as `unicode61 tokenchars '_.-/+'`, pinned against real fts5vocab
output so a query can be planned without a round trip. It decides the route
ladder's first rung (literal shape), strips stop words from prose but never
from a literal, and fans an identifier out into its pieces for the OR fallback.

Typo repair uses the index's own vocabulary as its dictionary, so it can never
suggest a term the index does not hold. Its exact-match probe now joins
`visible_messages`: `messages_vocab` is a view over the FTS b-tree and still
lists staged and tombstoned terms, and the PR 2 read ratchet is right to
demand the join.

`splitAiVaultSearchQuery` is the index's reading of repo: / path:. It keeps
operator case, which the panel folds and cwd_key must not; a census test pins
the two parsers to the same answer about what is an operator until PR 7 moves
the panel onto this one.

* feat(ai-vault-search): narrow a search the way the sidebar keys a folder

Every caller-supplied narrowing in one place, so retrieval, the operator-only
page and the session load cannot drift apart: agents, an updated-at floor, the
retention cutoff, scope paths, and the repo: / path: operators.

Scope keying goes through PR 2's `cwdKey`, which is the sidebar's
`folderGroupKey` without its prefix, rather than the original branch's second
spelling. That drops the branch's WSL distro qualification, which PR 2 removed
on purpose, and it makes the filesystem root a key that already ends in a
separator, so the child-prefix range is built from the key rather than by
appending one; `//` sorts below every real child and would scope the root to
nothing.

Engine types land here too, under src/main and not src/shared: nothing in this
PR is a wire type, and PR 5 lifts what a caller may receive.

* feat(ai-vault-search): rank, page and answer a session search

`SessionSearchEngine.search()` over the PR 2 store: route ladder (phrase, AND,
typo repair, OR), BM25 weights per corpus, one hit per session, fork folding,
and a page.

- `scope` picks the corpus and the engine never second-guesses it.
  `conversation` is user and assistant turns; `all` adds tool output and the
  identifier shadow column. Switching corpus while typing is PR 7's policy; an
  engine that widened on a miss would make a result impossible to reproduce
  from its own request.
- Pagination is an offset into one ranked list, fenced by the index generation
  and by a hash of everything that changes the ranking. A cursor from another
  generation or another query is refused with a typed error rather than
  silently re-run. Ranking breaks every tie by session id, because a cursor
  indexes into that order and retrieval does not promise one.
- Snippets and source presence are paid for by the page, not the list. A
  snippet past the per-hit ceiling is cut on a code point, never between `[[`
  and its `]]`, and flagged on the hit.
- Source presence is read from the `files` table. No stat on the query path,
  and no `missing`: only a proven deletion may claim one, and this read cannot
  prove it.
- `SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT` is an option, not a constant, and
  the result says when the limit was what cut the answer.
- The engine is the only caller of `store.warm()`, on first search.

Library only: no IPC, no settings, no Electron, nothing constructs it in
production.

* perf(ai-vault-search): measure what a query costs and what the candidate limit buys

Two corpora, because they answer different questions. The 10.5 MB / 40-session
corpus is what the scope split costs a reader: conversation is about 1.6x
faster at p50 and 3.4x at p95 than the full corpus, which is the argument for
the second FTS table being the one a keystroke can afford.

The candidate limit needs more sessions than the limit before it costs
anything, so it is swept over 2,500 one-turn transcripts with every session
matching. Limits are interleaved sample by sample: run back to back, the first
configuration pays for every page the OS cache had not seen and the ordering
alone moved p95 further than the limit did.

The doc says plainly what these numbers do not cover. They are cost, not
relevance; the MRR figures quoted beside the BM25 weights and the identifier
shadow column come from a shoot-out over real transcripts and cannot be
reproduced from this repository.

* refactor(ai-vault-search): key a page once per search, and report reachable pages honestly

The page key was hashed twice per search, once to decode the incoming cursor
and once to mint the outgoing one. The benchmark's reachable-page figure was
clamped against a constant that could never bind; it is the limit over the page
size and nothing else.

* test(ai-vault-search): pin the two engine seams nothing was holding

The warm wiring and the query-length cap were both written and neither was
observable. A spy pins that a search is what warms the store, and the cap is
pinned through the one input the planner's own term limit does not already
bound: a single enormous token, where the cut is what decides whether the term
matches the indexed one at all.

* fix(ai-vault-search): fence the generation against writers this process cannot see

The generation was cached in memory and moved only on this store's own writes,
and the bump itself was a read-then-write outside any transaction. Two handles
on one file is the normal case once PR 3 lands: the indexer writes in the
scanner child while an engine reads elsewhere. A reader would see that writer's
deletions while its own generation stood still, honour a stale cursor, and skip
a session; two writers could mint one generation for two snapshots.

`generation` now reads `meta` on every call, and the bump is a single
`ON CONFLICT DO UPDATE ... + 1` inside the transaction that makes the change.
That closes the crash hole the bump-on-open existed for, so opening a store no
longer invalidates anyone's cursor.

Only a change to what a read returns counts. Retiring a path the index never
held hides nothing, and the row deletes that drain a tombstone take away rows
that were already invisible: bumping there would refuse a cursor every 256 rows
and leave pagination unusable for as long as indexing ran.

Also drops redaction from the query log, following PR 2's decision to store
transcript content as written; the module it called no longer exists.

* fix(ai-vault-search): answer from a version-1 index, and keep a repaired literal whole

Two smaller findings.

An engine can be handed a connection to a version-1 file that another handle is
still answering from, and this PR is what first makes that reachable. It now
probes once for the two tables version 2 added and names what it cannot serve
on every result, instead of throwing at the first query that reaches for a
vocabulary that is not there. The route ladder simply skips its repair rung.
Which process may unlink and rebuild the index is PR 3b's decision and is not
solved here.

Typo repair re-planned the corrected query from scratch, and a corrected
spelling can read as prose even when what was typed was a literal:
`parseJsonn(the, data)` has the punctuation, `parsejson the data` does not, so
the re-plan dropped `the` as a stop word. The repaired query searched for less
than was asked and `repairedTerms` reported a body nobody typed. The re-plan is
now told what the original decided, because a repair changes spellings, not the
query's character.

`repairedTerms` is documented as the whole body the repaired plan ran, with the
index's own case-folded spelling for the terms it corrected.

* fix(ai-vault-search): make repo: and path: mean one thing in the list and the index

The engine said `repo:` and `path:` a second time, in SQL, and SQL cannot say
them. LIKE folds ASCII and nothing else, so `path:CAFÉ` missed `café`; the
engine searched `cwd_key` while the panel searches the working directory and
the transcript path, so `path:jsonl` matched every session in the panel and
none in the index; and the engine compared one path segment where the panel
compares the last two, so `repo:orca/session-search` missed. All four
reproduce in both directions.

So there is one definition now, not two that resemble each other.
`matchesAiVaultQueryOperators` moves into the shared filter module beside the
panel that already owned the semantics, the panel calls it, and the engine
applies it over the rows it retrieved. SQL keeps only what it can express
exactly: the `cwd_key` prefix range for `scopePaths`.

`parseVaultQuery` now parses through `splitAiVaultSearchQuery`, so one parser
decides what an operator is. Its existing tests pass unchanged; four degenerate
shapes do answer differently and are pinned as decisions rather than left to be
discovered.

Two consequences worth stating. The operators are conjunctive now, because that
is what the panel has always done, where the engine had been ORing within a
key. And the operator-only page walks newest sessions in bounded pages applying
the predicate, rather than taking one cut of the newest N and filtering it,
which would have answered `repo:x` with nothing on a busy index.

* docs(ai-vault-search): re-measure the query benchmark after the operator change

repo: and path: moved out of SQL, so the operator-only row measures something
different now and the note that it is a range seek was wrong. The rest of the
table is re-measured on an idle machine: the previous run's p95 column was
mostly contention, which is why conversation looked 3.4x faster at p95 rather
than the 1.7x it actually is.

Adds what this PR does not settle: which process may unlink and rebuild the
index is PR 3b's, and PR 4 is only the first thing that makes reading it
reachable.

* fix(ai-vault-search): stop reporting a search that gave up as a search that finished

The operator-only walk stops at a scan ceiling as well as at a full candidate
set, and only the first of those reached the result. A query whose one match
sat past the ceiling came back with no hits and truncated.candidates false,
which is the engine claiming there is nothing to find when what happened is
that it stopped looking. Retrieval now says why it stopped, because it is the
only layer that knows, and the count it used to return could not distinguish
the two cases.

The cursor fence stays as it is: any published read moves the generation, so an
outstanding cursor is refused, and that is what F11 asked for. What was wrong
was the claim next to the row-delete skip that pagination stays usable through
indexing. It does not, and the engine now says so. The rejection carries the
generation the cursor was minted in and the one the index is at, so a caller
can tell a moved index from a bad cursor and re-issue page one without showing
anyone an error.

The capability probe was nearly dead code, since every store opens through a
function that rebuilds a stale file. It is not dead, because two handles can be
open on one file, so the claim is corrected rather than the probe deleted. It
now runs per search: a verdict cached in the constructor is wrong in both
directions once another handle rebuilds the index.

Also says why the row-delete loop may skip the bump: those messages keep
batch_id NULL and stay in visible_messages, so what makes them unreachable is
their session's tombstone, and the read ratchet is what keeps every reader
joining the view that applies it.

* fix(ai-vault-search): restore the panel's reading of a quote that does not end a word

Unifying the two parsers changed panel behaviour on nine of twenty probed
shapes, not the three previously pinned. Six of the nine were regressions, all
from one rule: the shared parser refused a quoted span whose closing quote was
not followed by a space, so `"a b"c` and `repo:"a"b` became single terms
carrying their own quote characters, which match nothing.

The rule was justified as what stops the apostrophes in `it's a repo:orca
thing's` from swallowing the operator between them. It is not: a span only ever
opens at a token start, and the quote in `it's` is not at one. Dropping the
rule restores all six shapes to what the panel has always done and leaves that
protection intact.

Three changes remain and are kept because the old answer was worse in each: an
operator with an empty quoted value is dropped rather than filtering on `""`
and silently emptying the list, and a bare pair of quotes reads as an empty
term rather than as the two characters. Each is pinned with a test that says
which behaviour it is and why.

* fix(ai-vault-search): trim operator values, and report a query the engine had to cut

Three lows.

`repo:"  "` survived as a term and matched no label, silently emptying the
list, which is the exact defect the empty-value drop exists to prevent wearing
different clothes. Operator values are trimmed, and a whitespace-only one drops
like an empty one. The substring matcher's copy of a free-text term is trimmed
too, so `"  "` reads as the empty term already does; the span kept for FTS is
still the query verbatim.

Two caps upstream of retrieval fired silently: the planner searches at most 48
terms, and the engine cuts the query at 512 characters. A 56-term query whose
only match was the 56th came back with no hits and nothing truncated, which
claims there is nothing to find. `truncated.query` now says when either fired,
alongside the candidate and snippet flags that already did.

Every cursor refusal now carries the generation the index is at, which the
engine knows before it looks at the cursor, and the generation the cursor
claimed wherever that survived parsing. The doc says exactly when each is
present instead of leaving absence unexplained.

* refactor(ai-vault-search): read the tables the simplified index writes, and own the fence

PR 2 deleted the visibility views, the staging tables and the store's
generation, so this reads `sessions` and `messages` directly and carries the
three schema objects only a query needs — the vocabulary, the query log, and
the triggers that move the generation — as its own extension over the store's
schema.

The fence is now three triggers on `files`, because every transaction the store
opens that can change what a search returns writes that table and nothing else
does; retention's orphan drain is the one write path that touches neither, and
it is the one that must not bump. The triggers live in the file, so a writer in
another process moves the generation without knowing a reader exists.

A message row can now outlive its session row until the drain reaches it, so
the snippet read joins `sessions` and the typo repair asks for a live posting
instead of trusting the vocabulary's document count.

The engine takes a connection rather than a store: PR 2's store keeps its
connection private, and which process may open or rebuild the index file is
PR 3b's decision, not a query engine's.

* perf(ai-vault-search): price the second FTS table, and re-measure without warmup

Open decision 3. A column filter over `messages_fts` returns the identical
rowid set as `conversation_fts` — checked here per query rather than assumed —
so the table exists for latency alone. On a 105 MB corpus at both ends of the
tool-output band, the column-filtered form costs 1.16-1.42x at p95, against a
bar of 2x, so the recommendation is to delete it.

The shoot-out writes its own corpus because the answer turns on the one
property the shared generator fixes: how much of a transcript is tool output.
Half the tokens in that output are words the conversation also uses, which is
deliberately generous to the table under question.

The doc records the number that argues the other way. PR 2 priced the table at
about a quarter of the index on a corpus whose tool output is 56% of its
message text; on a tool-heavy one it is 6.7-11%, because `messages_fts` grows
with the tool text and the second table does not.

Page warmup is not re-added. The measurement behind it was on a 4 GB index,
removing it moves this corpus by less than the run-to-run spread, and a
cancellable background pass needs a lifecycle a query library does not have.

* test(ai-vault-search): pin that an append moves the generation a cursor is fenced by

* refactor(ai-vault-search): answer the conversation scope with a column filter

PR 2 deleted `conversation_fts` on the strength of this PR's shoot-out, so the
scope is a column filter over the one FTS table now. `ftsTableFor` is gone; a
scope is a pair of `scopedExpression` and `scopedWeights`, and the table name
no longer travels through the engine, the snippet builder or a hit.

The filter is parenthesised, and that is the whole of it: `{cols}: (a AND b)`
binds both terms, while `{cols}: a AND b` binds only the first and searches
tool output for the rest. A test drives an AND whose second term lives only in
tool output through both scopes.

The snippet keeps one guard, not two. Its column list and its expression were
each hiding the other's mistakes — a tool-only row was unreachable through
either — so the list is the same four columns for every scope and the scoped
expression is what makes a conversation snippet impossible to draw out of tool
output. Dropping it now leaks that row, which a test catches.

One behaviour the deleted table did not have, pinned rather than wished away:
bm25 normalises by the whole row's length and has no per-column length, so two
rows with identical prose score differently when one also holds tool output.
The rowid set is unchanged; the order within it can move.

Re-measured on the shipping schema. The conversation scope is 1.2-1.4x faster
than `all` at every rung, and the index is 57 MB rather than about 150 MB at
93% tool output, because a tool row is now capped at 3,072 characters.

* fix(ai-vault-search): repair a spelling inside the scope that will answer it

Typo repair read `messages_vocab` and probed `messages_fts` with no column
filter, so tool output decided whether a conversation-scoped query was
repaired, in both directions. A tool row carrying the misspelling made the
query look correctly spelled and suppressed the repair; a tool row carrying a
rare word became the suggestion, naming in `repairedTerms` a string from a
column the scope will never show. Both reproduced against a control index that
differs by exactly that one row.

The vocabulary proposes and a scoped count disposes. fts5vocab is per table and
cannot be column-filtered, so every decision that reaches the plan — already
spelled right, eligible, and which of two equally close candidates wins — now
comes from a `messages_fts MATCH` under the same filter retrieval uses, joined
to `sessions`.

That also takes the vocabulary's `doc` out of the ranking, which is the half of
the drain defect that belongs here: `doc` counts rows whose session a purge has
already cut loose, so reclaiming them changed which word a query was repaired
to. Candidates are ordered by term now, because the ordering decides which of
them survive the scan limit, and ties on similarity go to the more common word
counted live rather than to the vocabulary's number.

The cost is one bounded count per candidate examined, at most eight per prefix,
and only for a term the scope has no posting for at all.

* fix(ai-vault-search): fence the rows a purge reclaims after it cuts a session loose

Retention's second half deletes from `messages` alone and touched neither
`files` nor `sessions`, so it moved no generation. The argument was that those
rows answer nothing, which was true of retrieval and not of the engine: the
typo repair's dictionary is a view over the FTS b-tree and listed them, so a
drain running between two pages swapped the repair under a cursor that was
still honoured, and a search that had answered stopped answering.

The commit before this one fixes that at its source by counting live rows. It
does not make the drain provably inert — the vocabulary still decides which
candidates survive its scan limit, and reclaiming a term's last row moves where
that limit cuts — so the fence is what covers the rest.

A fourth trigger, on `messages`, with a `WHEN` clause that is the whole reason
it is affordable: a replace and a `removeFile` delete a session's rows while
its `sessions` row still stands, so neither fires, and both already bump
through `files`. Only the drain deletes a row whose session is gone.

The price is named rather than avoided: a cursor outstanding while a purge runs
is now refused once per batch, which `SessionSearchCursorError` reports as
`stale-generation` so a caller re-issues page one. The test that pinned the old
contract is replaced by one for the new one, and by one proving a replace still
does not fire it.

* fix(ai-vault-search): tell a highlight from a transcript that contains brackets

The snippet builder asked each of a row's four columns for a marked snippet and
showed the first whose text contained `[[`. Transcripts contain `[[`: a bash
`if [[ -f … ]]`, numpy's `[[1, 2], [3, 4]]`. A row matching only in tool output
was shown its user turn instead, with nothing highlighted in it, and the
any-column fallback an identifier-only match depends on was unreachable behind
the same collision.

Whether a column matched is now the difference between two renderings of the
same text: `snippet(…, MARK, MARK, …)` beside `snippet(…, '', '', …)`. Content
cannot forge a difference between those two, because it is the same content
either way.

The marks FTS5 inserts are private-use code points, rewritten to the public
`[[` and `]]` once, at the end. That is for the other decision that has to tell
a mark from content: the truncation refuses to cut between an open mark and its
close, and a transcript's own bracket used to move that cut.

* fix(ai-vault-search): cut a query on a code point and bind ids in batches

Two small ones from the review's not-routed list.

`query.slice(0, 512)` can land between the halves of a surrogate pair, leaving
a lone half that matches nothing and that a caller cannot echo back. The reader
already has `sliceAtCodeUnitLimit` for exactly this.

`loadSessions` bound one parameter per candidate id in a single statement. The
list is as long as the candidate limit, the tuning doc invites a host to raise
that limit, and SQLite's `SQLITE_MAX_VARIABLE_NUMBER` is 999 on builds older
than 3.32 — so one settings change away from `too many SQL variables`. Read in
batches of 500, leaving room for the filter's own bound values.

* docs(ai-vault-search): price the repair rung, and record what is left open

Typo repair is the one rung whose cost tracks the vocabulary rather than the
result, and it only runs for a term the scope has no posting for. Measured over
1.6 M distinct terms: 10 ms for one unknown term, 387 ms for a 480-character
query of thirty-nine of them.

The scoped-count fix made that cheaper rather than dearer, from 737 ms, because
ordering the vocabulary scan by term drops the sort `doc DESC` needed and the
counts it adds are at most eight bounded probes per prefix. A cap on unknown
terms per query is a follow-up in the split plan, with the five other items the
final review raised and did not route.

* test(ai-vault-search): make each snippet mark mechanism answer for itself

Two mechanisms landed together and hid each other: choosing a column by
comparing a marked rendering against an unmarked one, and marking with
private-use code points instead of `[[`. Either alone fixed the bracket repro,
so neither had a mutation against it — the same masking the snippet's two
column guards had a round ago.

They do different jobs, so both stay and each gets the test that needs it. A
transcript holding a private-use code point of its own is what the comparison
is for; agent output carries Nerd Font glyphs from that block. A snippet past
the character ceiling with a bracket after its last real mark is what the
private-use marks are for, because the truncation has to find that mark by
searching the text.

The two one-line fixes get honest framing rather than a mutation neither can
have. A lone surrogate is not a token character, so the planner drops it either
way and the safe cut is hygiene. And no SQLite this stack runs refuses 1,100
bound ids — 32,766 has been the floor since 3.32 — so the batch is about
owning the ceiling here rather than rescuing a reachable failure.

* refactor(ai-vault-search): keep the scope's expression with the other expressions

Making the typo repair ask its questions in the search's own scope put an
import from retrieval into it, and retrieval already owns the repair — a cycle
the native audit catches. `scopedExpression` belongs beside `phraseExpression`,
`andExpression` and `orExpression` anyway: it builds a MATCH expression, and
two callers now need it. The bm25 weights stay in retrieval, where the SQL that
uses them is.

* docs(ai-vault-search): say which delete paths fire the orphan-reclaim trigger after a replace cuts loose

* test(ai-vault-search): make the trigger-restore test exercise the trigger it drops

* fix(ai-vault-search): keep a scope nothing could key from widening the search

* fix(ai-vault-search): read a fractional or negative cursor generation as malformed

* fix(ai-vault-search): highlight only the marks FTS5 inserted, not the text's own

* test(ai-vault-search): compare the whole query, so a quoted operator value survives

* fix(ai-vault-search): filter routes and fence page reads; simplify query engine

* refactor(ai-vault-search): narrow retrieval API and clarify page rejection
2026-09-12 01:19:41 -04:00
Jinwoo Hong 1a9a5f9bc7 fix(cloud): tolerate sparse director errors in rollout monitor (#20238) 2026-09-12 01:15:15 -04:00
Jinwoo Hong 341b13cf67 Restore mobile push and fix cold-start dismissals (#20068)
* Restore mobile push for delivery validation

* fix(mobile): register push task before headless startup

* Add authenticated mobile push test and fix iOS release entitlements

* Mock push-test transport in notification consent tests

* Fix slept workspace test for structured remount result

* Fix mobile notification review findings

* Pad Android notification icon to prevent square cropping

* fix(mobile): present visible Android data pushes in foreground

* test: use deterministic clock for teardown deadline

* fix(mobile): present foreground pushes through Expo public APIs

* fix(mobile): check push eligibility before foreground scheduling

* fix(mobile): register push from shared host connection lifecycle
2026-09-12 01:03:57 -04:00
Jinwoo Hong 9f7fd9a270 fix(relay): reuse canary across completed rollout batches (#20214) 2026-09-12 00:46:46 -04:00
20ab995065 fix(codex): reconcile marketplace and plugin tables through the config mirror (#20150)
Scalar promotion omitted the marketplace and plugin tables, and the mirror rebuilt
ordinary config from canonical while only trust sections survived, so a managed-account
registration and refreshed provider metadata were both destroyed at the same boundary.

Registrations now reconcile through one baseline-aware pass before the canonical->runtime
copy: a runtime-only table is promoted, a table the canonical config removed since the
last mirror stays removed, canonical wins on an identity change, marketplace refresh
metadata is promoted only for a strictly newer valid timestamp with its paired revision,
and a plugin `enabled` toggle promotes only when the runtime alone changed it.

The settings baseline gains an optional `registrations` map at version 3. Absent means
never mirrored, which makes the v2 upgrade lossless; an older build rejects version 3 and
rebuilds, so downgrade is a safe degrade.

Verified end to end against a real codex-cli binary, which wrote the registration into a
managed home and read the promoted result back: `No marketplace plugins found.` becomes
`ponytail@ponytail  installed, enabled`.

Fixes #10489
Fixes #11770

Co-authored-by: BsTiger <96857444+Bongseop-Kim@users.noreply.github.com>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-09-11 21:22:10 -07:00
Neilandmaoking 438e0f4f5a fix(hooks): stop orphaned managed markers from consuming user TOML (#20148)
A managed block missing its end marker was treated as Orca-owned through EOF,
so uninstall/reinstall deleted appended user tables. The same shape existed a
second time in the Codex legacy profile cleanup.

Ownership is now two separate claims: a marker pair proves extent, and a
provider that can recognize its own emitted tables owns them wherever they
sit. An orphaned marker owns only its own line. Recognition uses the same
test for remove, install and status, so a table Orca cannot see is never one
it leaves running.

Co-authored-by: maoking <secretxierluo@gmail.com>

Fixes #18861
2026-09-11 21:22:06 -07:00
Jinwoo Hong f7238ce469 fix(relay): bound idle rehome polling and skip disabled scans (#20203)
* fix(relay): bound idle rehome polling and skip disabled scans

* test(relay): align sweep jitter expectation with polling budget
2026-09-11 23:58:24 -04:00
Brennan BensonandMerge Sim 76c8e91d4a fix(e2e): run worktree first-paint probe on a mapped window (#20197)
Co-authored-by: Merge Sim <sim@local>
2026-09-11 20:25:49 -07:00
Neil 3b13ce09a5 fix(source-control): pass the Antigravity prompt to agy --print (#20147) 2026-09-11 20:20:47 -07:00
Brennan BensonandMerge Sim 556a7772ed fix(e2e): remove four real flake sources and one caret race (#20169)
Four E2E specs failed once each across six main runs. Each traces to a
timing boundary the test could not control, not to product instability:

- linear-url-workspace-entry: pasted before X selection ownership landed,
  delivering stale text. Gate on a clipboard read-back.
- native-chat-first-flush-race: a bare 1_500ms sleep is exactly
  UNFLUSHED_SETTLE_MS, so it straddled the boundary deciding which of two
  hydration paths carried the test. Observe the not-yet-flushed read
  instead; a notFound is never cached, so this cannot perturb hydration.
- orchestration-idle-mail-delivery: asserted that a PTY -> daemon -> main
  round trip beats a 500ms production heuristic. Use the existing
  ORCA_E2E_ORCHESTRATION_POINTER_ENTER_DELAY_MS knob.
- tasks-page: the probe timeout was the one figure in the file not derived
  from GITHUB_TASK_SEARCH_IDLE_MS.

worktree.spec.ts exposed a real product race rather than a test bug: the
emoji caret-restore frame stayed armed through ordinary typing, so a
late frame could yank the caret back mid-input. Cancel it on the
non-emoji onChange path.

Also repairs a stale assertion: #20025 changed
remountTerminalTabForRecovery to return a result object and updated the
sibling call site but missed this one, so the comparison to `true` could
never pass. It is a deterministic break, not a flake.

Co-authored-by: Merge Sim <sim@local>
2026-09-11 18:37:16 -07:00
Jinwoo Hong 113e58f34e feat(relay): support protocol 3 in cell rollout gates (#20174)
* feat(relay): support protocol 3 in cell rollout gates

* fix(relay): validate and prove protocol-3 cell rollouts

* docs(relay): clarify regional capability deployment prerequisite

* test(relay): cover protocol-3 plans across rollout cells
2026-09-11 21:27:13 -04:00
Brennan BensonandMerge Sim 70a588c8bf fix(workspaces): complete a worktree create when a post-create step throws (#20175)
* fix(workspaces): complete a worktree create when a post-create step throws

executeWorktreeCreation's try/catch ends once createWorktree resolves, and all
three callers fire it with a bare void and no .catch. completeWorktreeCreation
is the only thing that removes the pending creation, so a throw in that tail
left pendingWorktreeCreations and activePendingCreationId set: the creation
surface stayed up, workspaceChromeActive went false, and the finished workspace
rendered no tab chrome while its panes mounted invisibly behind the panel. It
only cleared when the user switched workspaces, because setActiveWorktree nulls
the pointer. Silently -- no toast, no error state.

activateAndRevealWorktree, ensureWorktreeHasInitialTerminal and
ensureWebRuntimeWorktreeTerminalAfterWake are all synchronous with no internal
guard; launchStructuredWorktreeSession guards only its awaited launch, and that
catch's comment already names this stranding hazard.

The worktree exists past that point, so each follow-up step is now guarded
individually and falls back to the values the skip paths already used; control
flow always reaches completion. The structured-launch cancelled/visibility
returns keep their semantics, and a throw there is treated as a failed launch,
matching what that module already returns for 'failed'. A .catch backstop on
the three call sites turns anything that still escapes -- including
prepareRequestForCreate, whose VM await has try/finally with no catch -- into a
visible error state plus toast.

Ablated: with the guards removed the new suite fails 4 of 5, the survivor being
the no-throw control.

* fix(workspaces): recover terminal after partial activation

* fix(workspaces): preserve stamped launch tab on recovery

* test(workspaces): cover recovered agent tab delivery

* test(workspaces): name recovered agent delivery coverage

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 18:07:15 -07:00
Brennan BensonandMerge Sim 353a1c8038 fix(terminal): re-run deferred tab admission when a plan installs mid-activation (#20176)
planColdActivationTabDeferral can install an empty allowed set, deferring every
tab so the pane filter renders none. The drain that undoes that,
useActivationDeferredTabAdmission, depends only on backgroundMountRevision and
renderedActiveWorktreeId while reading the deferred set from a mutable ref, and
the install bumps neither: the only revision producers are the drain itself and
the background-mount event path, which the activation plan never reaches.

So this pass strands the workspace with zero panes: the worktree is already
rendered-active while the startup gate is closed, which resets
lastActivationWorktreeIdRef, then the gate opens on the same worktree and
installs a plan. Nothing re-runs the drain until the user switches workspaces
and back. The hook's own comment anticipates this launch shape and relies on
re-reading on growth, but that re-read only happens on a dep change.

applyTerminalColdActivation now returns activationDeferralPlanRevision, backed
by a ref in the parking foundation and incremented only when the plan actually
installs, which the admission effect takes as a dep. A ref rather than state
because the pass runs during render, where a setState would be a render-phase
update.

The 4-tab admission cap is deliberately untouched: it reproduces the warm set
an eager activation used to mount, so steady-state pane and WebGL-context
population is unchanged.

Ablated: with the change reverted the new suite's drain case fails on the
deferred set surviving the timers; the precondition and the away-and-back
control pass either way.

Co-authored-by: Merge Sim <sim@local>
2026-09-11 18:03:05 -07:00
Brennan BensonandMerge Sim 6252f8149b fix(browser): decode Chromium SameSite storage values (135 cookies silently lost per profile) (#20076)
* fix(browser): decode Chromium SameSite storage values

* fix(browser): document Firefox's real SameSite domain and pin its default-arm inputs

Third-reviewer finding: the replacement comment reproduced the failure mode this
PR exists to kill. It said "Firefox's moz_cookies uses the same 0/1/2 values",
which is true of the overlap and silently wrong about the rest of the domain.

Firefox writes 256 (nsICookie SAMESITE_UNSET) for every cookie with no SameSite
attribute -- the most common shape in a modern profile -- NULL on pre-v10 rows,
and 0 for explicit None OR a legacy unset row the schema-15 migration left
behind, which are not distinguishable in the column. All of those must land on
unspecified, so the default arm is load-bearing for Firefox rather than
incidental. A later reader making the switch exhaustive against the old comment
would have regressed Firefox silently.

Also pins the inputs that were riding the default untested: 256, and the
non-integer arrivals (null, undefined, NaN) that the `?? -1` scan fallback and
pre-v10 Firefox rows produce. Decoder behaviour is unchanged; 11/11 pass.

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 15:59:56 -07:00
Neil 9c918b93a6 fix(deps): take Electron 43.7.0 for the glibc environ use-after-free (#20089) 2026-09-11 15:43:43 -07:00
Brennan BensonandMerge Sim da5d555259 refactor(agent-status): delete the runtime's retained row store (PR 1b) (#19785)
* docs(agent-status): plan PR 1b at file level

Names the five RuntimeAgentRowStore call sites and what each becomes, why
terminalHandle has to be stamped before the store can go, and the one
intended behavior change.

* feat(agent-status): stamp the pane terminal handle on hook-server rows

The runtime's retained row store carried the pty binding two readers need. Put
that fact on the row that already owns the pane instead, resolved through the
same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot
disagree about which terminal a pane is.

Carried forward when a later write resolves no handle (only main's OSC parse
can), and never persisted: a handle belongs to the runtime that issued it.

* refactor(agent-status): route the session-tabs republish off the store

`retain()` was not only a duplicate store: its boolean return was the signal
that republished `session.tabs` for a status-only transition, which no title
change covers (#7970). `hook-status-session-tabs-invalidation.ts` already
mirrors that change set plus hook restore provenance, so route the signal off
the store rather than keep a second comparator.

Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out
deliberately skips — now load-bearing, because a dismissed row leaves the
listing at once.

Installed on both hosts. orcad had neither the OSC producer nor this signal, so
its runtime observed agent status and published it nowhere; deleting the
retained copy without wiring it would list no PTY agents there at all.

* refactor(agent-status): delete the runtime's duplicate retained row store

`RuntimeAgentRowStore` held the same payload the hook server already holds, so
the same pane could legitimately read differently in the sidebar, in
`worktree ps`, and on the phone. Both of its readers move onto the store's
snapshot in `runtime-hook-agent-row-selection.ts`, and
`collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook
reconciliation that only existed because two stores could disagree.

`ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`,
which is how a row still resolves the connected PTY behind it — the
working-terminal rollup's match key, and the last rescue for a row whose pane
binding a controller incarnation nulled under it.

The one intended behavior change: a row the user dismisses on the desktop
leaves `worktree ps` and mobile at once instead of lingering until the pty
exits. One store means one dismissal.

The suites written against the retained store are rewired to a real
AgentHookServer rather than deleted, so each still asserts the listing
behavior it named.

* docs(agent-status): record what PR 1b landed

Past tense, plus two corrections to the plan: `terminalHandle` is not the pty
id (they are different identifiers, and the explicit-status reader was already
comparing against a real handle), and the legacy numeric pane key is a
consequence the plan did not name.

* fix(agent-status): harden single-store lifecycle

* fix(agent-status): preserve mobile terminal rejoin

* fix(agent-status): preserve unverifiable remote rows

* fix(agent-status): own PTY row lifecycle in hook server

* fix(agent-status): preserve state and renew freshness

* fix(agent-status): ignore freshness for dismissed identity rows

* fix(agent-status): fence orcad observed identities

* fix(orcad): always release daemon adapter on cleanup

* fix(agent-status): cover remint and headless lifecycle edges

* fix agent status identity recovery gaps

* fix(agent-status): suppress duplicate child-only row mutation

* test(runtime): preserve hook store wiring in transcript harness

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 15:28:16 -07:00
Jinwoo Hong 729491597f feat(desktop): measure relay regions and reconnect after idle cutover (#20106) 2026-09-11 11:43:48 -07:00
Jinwoo Hong 1d7bb47a11 test(relay): harden regional rehome race coverage (#20136) 2026-09-11 11:42:03 -07:00
Jinwoo Hong cd9aa43a2c feat(relay): correct regional placement only when the source is idle (#20105)
* feat(relay): correct regional placement only at an idle source

* test(relay): lock source activity capacity semantics
2026-09-11 14:25:10 -04:00
Brennan BensonandMerge Sim 9a56797486 fix(mobile): surface host create warnings and terminal-create errors (#20125)
* fix(mobile): surface host create warnings and terminal-create errors

A workspace created from the phone could land on "No tabs in this session"
with a bare red "Failed to create terminal" and no way to tell why. Two
independent drops hid the host's own explanation:

- createWorktreeWithNameRetry returned only {worktreeId, name}, discarding
  worktree.create's `warning`, and hostNewWorktreeSessionRoute built the
  session route with only `name` + `created=1`. The session screen has always
  had the banner (MobileSessionContentRow + createWarningState) -- only the
  tasks create path ever fed it, so the New Workspace path could never report
  a startup terminal that failed to spawn.
- handleCreateTerminal collapsed every failure to the literal
  'Failed to create terminal', throwing away response.error.message.

Both now propagate, so the daemon's pty-allocation hint ("Your system cannot
allocate any more pty devices.") reaches the phone instead of dying in the
main process. Behaviour is otherwise unchanged: a blank warning is still
omitted from the route, and a host that gives no reason still reads
'Failed to create terminal'.

* test(mobile): refresh route parity baselines

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 11:21:31 -07:00
Jinwoo Hong ec9c3e0550 Fix remote hosted review browser routing (#20030)
* Fix remote hosted review browser routing

* Fix remote review modifier hint

* Address review feedback and fix routing test types

* Avoid assuming active runtime owns workspace links

* Align runtime routing regression expectation

* Respect explicit local link ownership
2026-09-11 14:20:06 -04:00
Brennan BensonandMerge Sim fb19c969a6 feat(native-chat): show when a Codex goal is set, changed, or cleared (#19923)
* feat(native-chat): show when a Codex goal is set, changed, or cleared

Codex never emits the model's `create_goal` call as an item, so
`thread/goal/updated` is the only truthful evidence that a goal exists. Both
goal notifications were classified `status-chrome`, which meant no typed
handler read them and no row was written -- the only thing reaching the reader
was the model's own prose. That prose can be wrong: in a session where no goal
was ever created the model still wrote "Goal created: ...".

Classify both frames as timeline-substantive and give them a sentence, so the
reader can tell a goal that exists from one the model merely claimed. Status is
translated rather than echoed, and an unrecognised future status still reads as
"Goal updated: <objective>" instead of the bare opcode.

Codex re-sends the goal as its token and time counters climb, so rows are
deduped on what a reader would notice -- objective, status and budget. A live
session sent the same goal three times in one turn with only accounting moving.

* fix(native-chat): write the goal signature separator as an escape, not a raw NUL

A literal NUL byte in the source made git classify the file as binary, which
hid its diff from review. The string built at runtime is unchanged.

* fix(native-chat): make Codex goal rows retry-safe

* fix(native-chat): preserve Codex goal identity on resume

* fix(codex): ignore empty goal clear snapshots

* fix(codex): preserve goal lifecycle across rewinds

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 11:00:09 -07:00
Jinwoo Hong 08a24efaba fix(push): preserve distinct Android alerts while offline (#20066) 2026-09-11 13:23:04 -04:00
github-actions[bot] 9aa0f7e77d Update README downloads badge 2026-09-11 12:36:35 +00:00
Neil a0799d8f1c fix(terminal): move the recovery ledger onto the tab row and gate it on observed outcome (#20025)
* fix(terminal): move the recovery ledger onto the tab row and gate it on outcome

The recovery budget lived in module-level Maps keyed by tabId. Anything keyed
outside the row needs a release path, and that release fired on every
remount-driven pane disposal, so each remount erased the budget it had just
consumed (crash b5cfc6ca). Put the ledger on TerminalTab and write it in the
same set() as the generation bump: reading the budget is now reading the tab,
so releasing it independently has no expression.

Counting was also the wrong control. Every remount mounts a pane that captures
a FRESH recovery epoch, so the epoch check can never refuse its request —
recovery re-requested the exact action that had just failed with no evidence
anything changed. Gate on an observed outcome instead, reusing the direct-SSH
pane retry vocabulary (success | failed | timed-out | superseded) and its
settle call sites: an unsettled attempt blocks the next one, and a settled
failure refuses the same reason until a new trigger arrives (generation move,
or the user's Retry). The 3-per-5min cap stays as a breadcrumb-emitting
backstop, not the control.

viewMode now also lands on the row from the local toggles, mirroring how pin
already does it, so the chat-ownership guard reads one index instead of OR-ing
two.

* fix(terminal): persist the row's viewMode and keep both chat-ownership reads

The narrowed chat-ownership guard read a field the session schema strips:
terminalTabSchema never declared viewMode, so the terminal row lost it on every
load while the unified tab kept it. After a restart the row read undefined and
recovery would remount a chat-owned tab's hidden surface — the race #19745's
guard exists to prevent.

Declare viewMode on terminalTabSchema so the row is durable, and keep the
disjunction rather than replacing it. The schema cannot retroactively add the
field to sessions already on disk, so the first load after upgrade still has it
only on the unified tab; and for a safety check over two partly-redundant
sources, a hole in either index should err toward declining a heal.

Also cover three structural guards that no test was holding: both remote
ledger-carry paths (terminal-build, remote-workspace-session-merge) and the
only success settle in the state machine, including its placement past the
failure branches.

* fix(terminal): settle a fresh spawn's outcome and prove the ownership guard across a reload

spawn-left-pane-unbound was the one recovery reason with no success settle:
its remount heals by spawning, not reattaching, so it reached none of the
reattach settle points and left the attempt 'pending' for the full 31s bound.
A fresh spawn that binds a PTY now reports it, the dual of the unbound settle
that already reported failure.

Two tests outside src/ still called remountTerminalTabForRecovery by its old
boolean contract and broke CI; both are updated to the admission result.

Also strips the client-local recovery ledger at the remote-workspace projection
boundary, in the type as well as the destructure, so a future producer cannot
put another machine's Date.now() on the wire.

* fix(terminal): resolve the pane's tab row once for both epochs after the main merge

#20034 replaced connect-pane-pty's inline tab resolution with
findTerminalTabForPane, and this branch had rewritten the line below it to read
the recovery epoch off the row that block used to bind. The merge was textually
clean and semantically broken: `terminalTab` no longer existed, so typecheck
failed and every test that connects a pane threw ReferenceError.

Resolve the row once through the new helper and feed both epochs from it, which
keeps #20034's refactor and this branch's reason for reading the row here — a
second lookup would put another tabsByWorktree scan on the connect path.

captureTabRecoveryGeneration is narrowed to the one field it reads so the
helper's record type can carry it.
2026-09-11 04:50:42 -07:00
Neil 94c2f96ea4 perf(daemon): stop scanning every cell for OSC links that cannot exist (#20077)
collectHeadlessOscLinkRanges walks every cell of every row on each snapshot,
and called xterm's getCell without the reuse argument its own docs recommend,
so a link-free scrollback paid a CellData allocation per cell for a guaranteed
empty result. Skip the scan when xterm holds no OSC 8 registration, and reuse
one cell when it does.

Measured over a 5000-row link-free buffer at 200 cols, same harness back to
back, median of 25: 43.85ms -> 0.00ms.

This is our bug, not xterm's: xterm already reuses cells in its own serializer
and documents the getCell(x, cell) overload for exactly this.
2026-09-11 01:50:13 -07:00
20c56249d5 fix(terminal): keep a deliberately slept workspace cold until it is woken (#20075)
* fix(terminal): keep a deliberately slept workspace cold until it is woken

Sleeping a workspace kills its PTYs but keeps its panes mounted and keeps each
tab's session id as a wake hint. Any later remount of those panes (recovery,
parking, portals) reattached that dead id, and the daemon's create-or-attach
spawned a fresh shell, so slept workspaces revived on their own (#10205).

The existing sleep-intent marker now outlives teardown and gates the deferred
connect itself, so both the reattach and fresh-spawn arms stay cold. It is
released by activating the workspace, by any PTY binding to one of its tabs
(CLI, automation, client wake), and by purge. A queued startup still connects.

Reproduces the community root cause from gatsby74 in #13343; the regression
e2e remounts a slept hidden pane and fails on main.

Co-authored-by: gatsby74 <gatsby74@users.noreply.github.com>

Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>

* fix(terminal): let a slept pane wait for its wake instead of latching cold

A pane whose connect ran while its workspace was slept used to mark itself
connected and stop; nothing re-armed it, so a wake that produced a live PTY
before the user clicked (CLI create, background agent resume, split panes)
left panes stranded. The connect now waits on the sleep marker and resumes
when the marker clears, and a torn-down pane drops its listener.

Tabs created with a live PTY clear the marker too, the sleep flow marks each
workspace only when its own teardown starts, and purge forgets the marker
without waking anything.

* fix(terminal): wake a waiting pane once, in its remounted generation

Activation clears the sleep marker after the set() that bumps dead tabs'
generations, and the waiting pane only resumes its connect when its tab
generation is still current. Otherwise the stale pane and its remounted
successor both reattached the same session id on a deliberate wake.

* fix(terminal): resolve the waiting pane's tab by either id and re-arm after wake

The wake listener looked the tab up by the pane's render id, which can be a
unified id whose terminal tab lives under entityId, so the generation check
declined forever for those panes. Mount, fresh spawn, and the wake listener now
share one live resolver. The wait flag resets when the listener fires so a
second sleep can hold the pane again, listener dispatch is guarded, folder
activation clears after its own set(), and the sleep flow re-asserts the marker
after each teardown while releasing a workspace the user activated meanwhile.

* fix(terminal): ignore PTY binds that land inside the sleep teardown window

A spawn resolving while shutdown was still awaiting the host bound a PTY and
cleared the marker, waking every waiting pane mid-sleep; re-marking afterwards
could not un-connect them. The sleep flow now scopes each teardown so binds in
that window are not wakes. The e2e asserts a deliberate wake yields exactly one
PTY, and the dispose test proves the listener is gone.

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>
2026-09-11 01:45:57 -07:00
Neil 22d12388a5 fix(pi): load extension providers for source control generation (#20070) 2026-09-11 01:27:15 -07:00
Brennan BensonandNeil 78e985cd99 fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245) (#16631)
* fix(pi): claim the status pane when the inherited owner PID is dead (STA-5245)

The managed pi/omp/prime-agent status extension suppressed itself whenever
ORCA_PI_STATUS_OWNED held a PID other than its own, with no check that the
owner still existed. A restart leaves the previous owner's PID in the
inherited env, so every later load returned early and the pane stopped
reporting status permanently.

Probe the owner before suppressing. Only ESRCH proves it is gone; any other
probe result keeps suppression so a live foreign owner still cannot
double-report. This mirrors the tri-state in
main/agent-hooks/managed-hook-owner-identity.ts, which the extension cannot
import because it loads inside the pi/omp runtime with no Orca deps.

Also extracts the generated-source test harness into its own module so the
suite stays under the max-lines limit.

* fix(pi): validate inherited status owner pid markers

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-11 01:25:17 -07:00
Neil 6bb2b0c6d7 test(runtime): capture real Antigravity transcripts — the detector is inverted on live output (#19983)
* test(runtime): capture real agent PTY transcripts before rewriting Antigravity readiness

Antigravity readiness has been written five times against a five-line screen
typed from memory. There is no Antigravity transcript in this repository, so
every attempt was a guess tested against another guess. This adds the recorder,
the protocol and the fixture-driven suite so the sixth attempt can be written
against evidence, and changes no detector logic.

- config/scripts/capture-agent-pty-transcript.mjs records a live agent session
  through a real PTY, escapes and wrapping intact. Ctrl-] is consumed by the
  recorder and never forwarded, which is the only way to end a capture while a
  dialog still owns the screen.
- config/scripts/pty-transcript-secret-scan.mjs finds account identifiers and
  credentials, redacts them with same-length placeholders so wrapping survives,
  and recognises its own placeholders so a scrubbed file verifies clean.
- src/main/runtime/antigravity-readiness-transcripts.test.ts asserts a verdict
  per transcript and skips by name until the transcripts land, with a
  doc-coverage ratchet and a guard that a fixture contains escape bytes.

The escape-byte guard exists because the three cursor-agent fixtures carry a
comment claiming they were captured verbatim through Orca, yet contain zero ESC
bytes and zero carriage returns. That comment is corrected here to say what
those files are; the fixtures and the rules built on them are untouched.

* test(runtime): capture real Antigravity transcripts, and pin what they prove

`agy` 1.1.25 turned out to be installed, so the transcripts this scaffold was
built for now exist. Six are recorded from live sessions and committed; the
rest are named as skipped, because reaching them would mean signing the
operator out or deleting their config.

The captures invert the story. On real output the shipped detector refuses a
genuinely ready screen and accepts a live `/model` picker:

- Antigravity paints a block-glyph logo down the left, so the model row never
  starts a line. `startsWith('gemini', trimmedStart)` cannot match a real ready
  screen, on any account or model. Stripping the logo flips the same screen to
  ready, which means a decorative glyph decides readiness today.
- The `/model` picker prints `Gemini 3.x Flash` one per line, at line start, and
  a bare `>` composer sits earlier in the tail. Both halves of the rule are
  satisfied while a dialog owns the screen.
- For an API-key user the identity row reads `Gemini API key` — no `@`, no
  domain — and `AGY_CLI_HIDE_ACCOUNT_INFO=1` removes the row entirely. The
  account-row requirement of attempts 4 and 5 can never pass for those users.
- The banner is printed once and never reprinted after a dialog is dismissed, so
  `headerIndex` cannot be the ordering anchor.

Four suite cases are pinned as KNOWN DEFECT: they assert what the detector does
so CI stays honest instead of permanently red, and flip to failing the moment
someone fixes it. No detector logic changed.

The recorder gains `--send "<ms>:<text>"` because a dialog capture has to be
driven and an unattended run has no TTY, and the scrub scanner gains a UUID rule
because agy prints a resumable conversation id on exit.

* test(runtime): capture agy mid-turn, and make the scan file reviewable

Answers the busy-frame question a P1 review raised against attempt six, with
two new captures from a live turn.

At the frame level the review is right: a busy frame parks the caret with the
same bytes as an idle one, `CR ESC[2A ESC[2C`, and the only differing row —
`esc to cancel` versus `? for shortcuts` — is erased by that park.

At the retained-tail level it does not reproduce. Each spinner tick is its own
repaint with its own `CR ESC[2A`, two rows higher than the frame's, which
splices the composer away: a live turn's tail ends on `⣟  Generating...`, with
no bare caret to match. A constructed input that keeps the park and edits only
the status text is not faithful, because a live turn has a spinner row
repainting below the composer.

The residual is the gap between a frame park and the next tick, where the tail
does end on the bare caret. Quiescence-gated paths are safe there because ticks
keep arriving; text-only paths are not, and for those the capture supports one
clause: a braille glyph on the last visible line means working. That predicate
already exists here for cursor-agent and should be reused, scoped to the last
line — a first-run transcript prints `⠾ Signing in...` during startup.

Also in this commit, from the same review:

- pty-transcript-secret-scan.mjs held raw 0x00-0x1f bytes in a character class,
  so the one file gating real PTY data into history was binary to git and
  unreviewable in a diff. It now tests codepoints, which the formatter cannot
  fold back into control bytes.
- Pin `src/main/runtime/__fixtures__/*.txt` as -text. A Windows checkout would
  otherwise normalise line endings and rewrite the CR bytes that make these
  files evidence.

The recorder now stops appending at the stop moment rather than through
shutdown: an agent repaints an idle frame on its way out, which was overwriting
the mid-turn state the capture existed to record.

* test(tooling): allowlist the transcript scan test in the batch-shim ratchet

pty-transcript-secret-scan.test.mjs asserts that the capture recorder routes
an 'agy.cmd' shim through cmd.exe, so the shim literal it names is the
assertion, not a spawn. Fits the existing assert-on-shim-files category.
2026-09-11 01:07:16 -07:00
Neil 2167cd2994 test(monaco): drive the real Monarch tokenizer instead of walking rule tables (#19981)
* test(monaco): drive the real Monarch tokenizer instead of walking rule tables

* fix(monaco): stop a truncated JSONL record poisoning every record after it

`@string` was pushed unconditionally, and Monarch state survives the line
break, so one truncated record left every later record inside the string state
— the whole rest of the file rendered as a single string. A truncated record is
a normal way for a .jsonl log to end.

Gate the push on a lookahead proving the closing quote is on this line, and
consume an unterminated remainder as `string.invalid` without pushing anything.

Measured against the real tokenizer over realistic JSONL. Two alternatives were
rejected: collapsing the string into one regex fixes the poisoning but loses
every `string.escape` token on well-formed records, and `includeLF` does not
fix the primary case at all (the `[^"\\]+` content rule swallows the newline
before an EOL rule can match). This candidate's token stream is byte-identical
to the previous grammar on every well-formed record — the existing inline
snapshot did not move — and is at or faster than it on 20k pathological lines.

* test(monaco): pin monaco's own mdx grammar as the embed-recursion regression

Upstream's shipped mdx grammar enters a `js` embed on every `{` and pops
on `}` with no budget, so it reproduces the unbounded embed-entry
recursion exactly — evidence the shape is monaco's, not something Orca's
grammars invented, and a tripwire for a monaco upgrade that changes it.
The same file proves an Orca grammar stays inside the budget under the
identical line. Measured here: 500 interpolations -> 500 frames, no error;
3000 chars -> RangeError at 945 frames. The ceiling is runtime-dependent,
so the test asserts the failure, not the number.

Adds a regression for astro's `^`-anchored frontmatter pop rule
(monaco-editor#1127) so an indented or trailing `---` cannot close the
fence early, de-duplicates the line-cap constant onto the budget module's
`MAX_TOKENIZATION_LINE_LENGTH`, and renames the recursion suite: embeds
cannot nest, so "embedded recursion depth" described the wrong thing.
2026-09-11 00:58:10 -07:00
Neil 5fa62feda7 perf(terminal): mount only the visible pane on a worktree switch (#20034)
* perf(terminal): mount only the visible pane on a worktree switch

Activating a worktree mounted a TerminalPane for every tab it holds, not just
the one on screen. Cold-activation deferral existed for this but engaged only
past four deferrable hidden tabs, which exempted the 2-5 tab worktrees that
make up almost every real switch.

Deferral now engages for any deferrable hidden tab, and the siblings it skips
are admitted one per idle frame after the reveal, capped at the population the
old threshold would have mounted eagerly. Steady-state pane, WebGL-context and
heap population are therefore unchanged; only the frame the mounts land on
moved.

* fix(terminal): judge admission eligibility on the largest deferred set seen

Review found the launch worktree never warms up: it is restored active before
hydration opens the startup gate, so admission read an empty deferred set,
cached ineligible, and never recomputed once the real plan landed. Judge on the
high-water mark instead - an over-cap worktree still stays ineligible as its set
drains, but a later plan is seen.

Also from review: the e2e WebGL counter read getPanes(), which returns a public
projection with no webglAddon field, so it was always 0; read
getRenderingDiagnostics() instead. Filler worktrees now clean up on failure
(testRepoPath is worker-scoped), and the restore metric is named for what it
measures rather than implying a pixel assertion.

* test(e2e): wait for the reveal to restore, and scope the latency budget off CI

CI failed with 'revealed terminal never restored its content': the harness
sampled a fixed 4s window, which a shared runner can outlast, so a slow restore
was recorded as no restore. Poll for the restore instead.

Also stop asserting a latency budget on CI. Shared runners cannot hold a
threshold; the structural invariants (one pane mounted by the switch, warm set
restored) are exact and stay asserted everywhere.
2026-09-11 00:47:57 -07:00
Brennan BensonandMerge Sim fab78c7669 fix(native-chat): show one live-turn indicator, and make Thinking mean reasoning (#19977)
* native-chat: render one indicator row for the live desktop turn

The turn-timing row and the spinner+activity line were two rows saying
"Working" at once. A settled turn keeps its own row; the live turn now has
only the spinner row, labelled provider activity -> Thinking -> Working for N
through the shared resolver. Reasoning is the turn's content, so it no longer
becomes the activity label, and "Thinking" now means the turn is reasoning
right now rather than that it has produced no output yet.

* mobile: give the live turn row a spinner and the shared indicator label

Mobile's per-turn row is already the only live indicator on the structured
lane, but it pulsed a bare word and never showed what the provider said it was
doing. It now renders a spinner beside the same resolved label desktop uses,
and reads reasoning from the journal instead of inferring it from missing
output. The bridge lane's four prompt/interrupt write seams move to one module
so the controller stays under its line cap.

* codex: mark streamed reasoning as reasoning too, and pin the provider markers

The settled reasoning item carried the marker but the streaming one did not,
so a live Codex turn - the only time the indicator is on screen - never read
as reasoning. Both paths now stamp it; a plan document keeps its own
presentation and must never read as reasoning.

* fix(native-chat): tighten live turn reasoning state

---------

Co-authored-by: Merge Sim <sim@local>
2026-09-11 00:19:57 -07:00
Jinwoo HongandOmar Shahine 3b82d8de64 fix(runtime): let connections own host status recovery (#20003)
* fix(runtime): let connections own host status recovery

Verify runtime status after authenticated connection recovery and publish
ordered snapshots to desktop and browser viewers. Consolidate failed-status
retries in the connection owner and remove renderer retry/diagnostics merging.

Adapt sidebar host-state derivation and regression coverage from Omar
Shahine's original fix in https://github.com/stablyai/orca/pull/19163.

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>

* fix(runtime): show blocked hosts honestly and remove obsolete status options

* fix(runtime): preserve timeout guidance and update IPC test fixtures

* fix(runtime): preserve status evidence and address review gaps

* test(sidebar): assert workspace host icons dimming and recovery tooltips

* fix(palette): require available hosts before adding implicit badges

* fix: retain disconnected host snapshots for new renderers

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-09-11 03:19:01 -04:00