* perf(terminal): eliminate dense control and frame gate regressions
* test(terminal): keep gate labels in valid expect shape
* test(terminal): expose the surviving sub-threshold control-density case
The only adverse strip fixture sat at 50% control density, which is exactly
where the fallback fires and wins. A shape at 31 controls per 64-unit block
evades the trigger and still loses to the per-character legacy (0.67x), so the
benchmark structurally could not show it.
Add that fixture, pin both density literals in the staleness guard so a retune
fails loudly instead of silently measuring a boundary that moved, and export
the probe constant the equivalence test was hardcoding.
* perf(editor): cut per-keystroke work on two rich-markdown paths
Doc links: both plugins walked every text node and ran matchAll on each — the
auto-convert appendTransaction once per keystroke, the preview decorations once
per keystroke and again per caret move. A link needs `[[`, so gate on a native
substring check first. The two walks had duplicated their guard sequence; they
now share one predicate. 3.1x-3.8x over the repo's own markdown.
Annotations: resolving a comment's block re-serializes the whole document (every
node, plus every adjacent pair), and both the highlight-range and
comment-at-position paths did that once per comment — O(comments x document).
Build the blocks once and pass them down. On a 12-node fixture with 8 comments
that is 184 serializations down to 23.
* test(editor): pin the one-build serialization baseline
Review feedback, all four points:
- The serialize-count assertions compared many-comments against one-comment, so
they would have passed if BOTH built blocks twice. Pin the absolute count
(23 = 12 nodes + 11 adjacent pairs) derived from the fixture size, so a
regression to per-comment building fails instead of comparing equal. Verified
by reverting the hoist: 2 tests fail.
- Skip an empty benchmark corpus instead of evaluating `index % 0` and
dereferencing undefined.
- Build fixture paths with path.join.
- Condense the benchmark header to purpose plus parity guarantee.
Co-authored-by: Orca <help@stably.ai>
* test(editor): harden doc-link performance evidence
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(git): overlap getBranchCompare's head-of-chain reads
Four git spawns ran strictly in series before any compare work began:
branch --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>.
Three are independent -- compareRef is display-only metadata and HEAD's oid does
not depend on the base ref -- so they now run concurrently. The fourth was
redundant outright: the probe already runs `rev-parse --verify --quiet
<ref>^{commit}` and discarded the oid it printed, which was then re-resolved by a
second spawn. resolveWorktreeBaseCommitOid returns that oid so it can be reused;
hasWorktreeBaseCommitRef now delegates to it, leaving its other 4 callers
untouched.
3.6-3.7x on a short remote base label (192ms -> 52ms), 1.44x on an
already-qualified refs/... base, which skips the probe by design.
Reuse is keyed by ref: resolveWorktreeAddBaseRef returns at its first successful
candidate, so only that ref's oid is ever read back. Peeling is safe because only
refs/heads and refs/remotes candidates reach the probe, where ^{commit} is a
no-op.
No new git features: this removes a spawn rather than adopting an option.
Co-authored-by: Orca <help@stably.ai>
* fix(git): preserve compare semantics across providers
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
src/cli/index.ts was the only eager value-import of RuntimeClient, and five
other eager modules imported just RuntimeClientError / RuntimeRpcFailureError
from the runtime-client barrel -- dragging in client -> pairing -> zod -> ws
-> e2ee on every invocation. Those error classes live in runtime/types.ts,
which has zero children, so the five imports now point there and the client
loads through the existing (already lazy by design) ctx.client getter.
Eager modules 199 -> 46, with node_modules dropping 94 -> 0.
`orca --help` 2.04x (59.6 -> 29.2 ms); the same for help, no-args, and both
error paths, which return before constructing a client. Commands that DO
construct one still gain 1.10-1.12x from not eagerly parsing the transport
the local path never uses.
Correction to an earlier note: websocket-transport alone is ~24 modules /
~8 ms, not the 107 / 28 ms once recorded -- that figure wrongly charged it
for zod, which enters through shared/pairing on a different edge. Marginal
cost, never isolated cost.
Co-authored-by: Orca <help@stably.ai>
* perf(terminal): measure stream byte length natively above a code-unit floor
The terminal RPC path counted UTF-8 bytes with a hand-rolled per-code-point scan
that Buffer.byteLength does natively an order of magnitude faster.
Routed through a small module rather than swapping the shared clipboard helper,
which has ~50 renderer call sites and a partial-count contract on the over-limit
path that must not change.
4.4x on an 8KiB batcher push, 4.2x on a 2MiB snapshot scan, 4.0x on the 48KiB
chunk gate, and 1.26-1.33x on the adversarial early-trip shapes where the legacy
scan bails after a third of the string.
The floor is load-bearing, not defensive. Buffer.byteLength has a fixed ~14ns
call cost against a scan iteration of ~1.5ns, so below the measured 8-12 code
unit crossover the native call is a REGRESSION -- 4.2x slower at one code unit,
which is keystroke echo, the most latency-sensitive PTY shape there is. Short
inputs keep the scan verbatim; 16 leaves margin over the crossover so the worst
sub-floor shape stays at parity.
measureTerminalStreamByteLength takes the native count only when
`length * 3 <= stopAfterBytes` proves the limit cannot trip, so the callers'
truncated running total is never replaced by a full count.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): benchmark production byte-length exports
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(terminal): scan output frames by code unit, not per code point
iterateTerminalOutputFrameChunks walked `for (const part of data)`, materializing
a 1-2 character string per code point and calling terminalStreamByteLength on
each, while accumulating the frame text with `chunk += part`.
The accumulator was never needed: `chunk` only ever reconstructs the contiguous
substring data[chunkStart..end), and `startSeq + chunkStartOffset + chunk.length`
collapses algebraically to `startSeq + end`. Track two integer offsets and emit
data.slice(chunkStart, end) instead, computing UTF-8 width inline from charCodeAt.
Also short-circuits the cap gate on UTF-16 length before measuring UTF-8 bytes,
which is sound because UTF-8 length is never below UTF-16 length.
2.4-6.1x across payload shapes, stable across reruns. This runs per terminal
output batch and per snapshot chunk.
Extracted to its own module along a real seam (the chunk-emission concern plus
its two types and cap gate); methods/terminal.ts shrinks by 85 lines. No
max-lines suppression added and the baseline is untouched.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): preserve chunk sequence rounding
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(store): index worktrees and tabs once per hydration loop
Four sites in hydrateWorkspaceSession and reconnectPersistedTerminals rebuilt a
flattened array inside a loop and linearly searched it:
Object.values(worktreesByRepo).flat().find((e) => e.id === worktreeId)
That is O(rows x ids) for O(rows + ids) distinct work. Build one first-wins index
per loop instead. Neither loop sets state or awaits, so a single index over the
store snapshot is valid for every iteration.
54.5x at a real 10-repo / 423-worktree session with 188 pending reconnects;
2.1x on a one-repo session. This runs synchronously on renderer cold start and
gates workspaceSessionReady, which blocks terminal pane mounting.
First-wins matters: Array.prototype.find returns the first match, so an index
that overwrote on collision would resolve a different repo for a duplicated
worktree id. Both the tests and the benchmark fixture carry a deliberate
cross-repo duplicate so that difference is observable.
Co-authored-by: Orca <help@stably.ai>
* test(store): count the generated worktree rows instead of multiplying
Review feedback: the table printed repoCount * worktreesPerRepo, which misses the
duplicate id makeStore injects for multi-repo cases (that duplicate is what makes
first-wins observable). Count the generated map, and say plainly that the fixtures
are synthetic at real-world scale rather than a replay of a real session.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(relay): keep the PTY replay window as chunks, not a re-sliced string
appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)`
once over the 100KB cap. It runs per raw node-pty emission -- before batching --
so once a PTY saturates the window (which a long-lived shell does almost
immediately) every subsequent chunk copied the whole 100KB.
Reuse RecentPtyOutputBuffer, which already solved this shape in the main process:
keep chunks, drop from the head, defer the join to read(). The relay's three
readers are attach, adopt, and revive only.
66-205x on the append path, per PTY, on the user's SSH host.
RecentPtyOutputBuffer's limit is now configurable, because the relay retains
100KB where the main process retains 64KB. One arithmetic branch still used the
hardcoded constant after that change and silently under-retained (100,800 of
102,400 code units); the equivalence tests caught it before it shipped, and the
suite now pins the configured limit directly.
Co-authored-by: Orca <help@stably.ai>
* test(relay): exercise a real surrogate split; drop eval from the benchmark
Review feedback, both valid:
- The surrogate test never split a pair. The cap is even and a pair is two code
units, so an emoji run alone always cuts on a pair boundary. A trailing single
unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) --
asserted directly now, with the boundary-aligned case kept as its own test.
- Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already
admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval
regardless of the eslint suppression.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* feat(updater): switch to validated local mac builds
* test(updater): cover local build recovery actions
* fix(types): keep local build contract in project sources
Reviewed with an independent reproduction. Added the allowlist entry that unblocked verify:localization-coverage on main, the 4th drifted step, and a parity gate that fails when pnpm lint's chain contains a script absent from pr.yml.
* fix(release): restore the Windows inner-binary signature gate
electron-builder 26.9+ dropped the bundled 7zip-bin package, so the gate's
hardcoded node_modules/7zip-bin path stopped resolving in 1d2cd33c83. The
gate is fail-open, so it swallowed the error and 11 releases shipped with
no signature verification and an evidence artifact that looked clean.
Resolve 7za through app-builder-lib's toolset instead, and always record a
verdict so a degraded gate can't pass for a healthy one.
Refs #6487
* test(release): make the signing-gate structural tests assert executed code, not text
The round-2 harness matched /\bthrow\b/ and /\bcatch\b/ against raw block text, so
the word satisfied the assertion wherever it appeared. Downgrading the resolver
throw to `Write-Host "...would normally throw..."` — the exact silent fail-open
this PR exists to kill — left all 11 tests green.
Every span is now classified once (code / string / comment) by the same walk that
pairs braces, and assertions run against the string-and-comment-blanked view.
Blanking preserves length, so indices still line up across views.
Also re-anchors the catch-ordering test: `blockAfter(step, '} catch {')` picked
the first catch in the step, which stopped being the gate's own once the
persistence helpers grew theirs — moving the policy throw inside the try was
passing again.
Co-authored-by: Orca <help@stably.ai>
* test(release): pin the evidence filename the gate writes to the one the upload collects
The upload step is `if-no-files-found: ignore`, so renaming the evidence file on
one side and not the other ships a green run whose artifact silently omits the
verdict — the same silent-degradation class this PR exists to close.
Co-authored-by: Orca <help@stably.ai>
* fix(release): preserve 7za resolver failures
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* perf(agent-status): strip terminal control bytes by run, not per character
stripTerminalControl built its result with a per-character `+=`, allocating a
fresh string for every retained character. The Command Code status detector
calls it four times per PTY chunk — the scan text, the chunk-boundary variant,
and both previous-text lengths — so an agent pane paid that on every write.
Control bytes are sparse in real output, so copy the spans between them instead:
2.3x-2.6x from 5 KiB to 106 KiB chunks. Output is byte-identical, checked
exhaustively over every string up to length 4 across a 13-symbol control/unicode
alphabet plus 200k random strings (224,831 inputs, 0 mismatches).
* docs(agent-status): condense the run-copy rationale comments
Review feedback: both comments walked through the implementation. Keep one line
of non-obvious rationale each, per the repo's comment guidelines.
Co-authored-by: Orca <help@stably.ai>
* test(agent-status): correct terminal strip benchmark
* test(agent-status): bound terminal strip benchmark
---------
Co-authored-by: Orca <help@stably.ai>
* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)
Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.
Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.
* fix(plugins): make unsupported marketplace listings unreachable by key
findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.
* fix(plugins): fan Pi session-only status out to plugin subscribers
The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.
Co-authored-by: Orca <help@stably.ai>
* plugins: drop dead code and hoist duplicated trust-boundary patterns
Cleanup pass over the P1 diff, no behavior change:
- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
`mapWithConcurrency`, whose index arg also removes the pairing wrapper
in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
paths, matching every other gate's link form.
* fix(plugins): retry plugin state renames on Windows AV/EPERM locks
Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.
Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.
* test(plugins): cover the Windows rename retry path
The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): pin bundled plugin resources to LF
Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.
Co-authored-by: Orca <help@stably.ai>
* test: guard the bundled-plugin LF pin against a CRLF checkout
The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.
Co-authored-by: Orca <help@stably.ai>
* ci: trigger packaged-build check on bundled plugin resource changes
The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.
Co-authored-by: Orca <help@stably.ai>
* perf(plugins): rebuild the panel frame only when its baked theme values change
The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.
Co-authored-by: Orca <help@stably.ai>
* test: stop pinning a plugin name in the CRLF guard
The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.
Co-authored-by: Orca <help@stably.ai>
* style: drop stray blank lines left by the rebase resolutions
Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.
Co-authored-by: Orca <help@stably.ai>
* test(plugins): stop the startup budget failing on machine load
P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(mobile-sync): memoize the agent-status projection per entry
buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every
status ping. setAgentStatus replaces one entry and re-spreads the map, which
defeats the reference-equality skip gate, so each ping paid for every other
agent's prompt, 20-entry history, and 8 KB assistant message to discover they
had not changed.
Memoize each row's JSON by entry identity, the cachedTabsProjection pattern
already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents,
0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized
rows matches whole-array stringify, which the new test pins against a verbatim
copy of the old implementation.
* test(perf): stop inflating the projection benchmark baseline
The pre-fix arm stringified each row and parsed it back before stringifying the
array, a per-row roundtrip the original never paid. That made the baseline
artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x.
Share one row builder between both arms, and check equivalence after a ping as
well as on the cold call — a stale-row bug can only surface once the cache is
actually exercised, which the cold-path check could never catch.
* perf(git): read both diff blobs concurrently
The diff loaders awaited their two sides in series, so the second `git show`
could not start until the first had returned. The reads are independent, so
that was pure added latency on every diff the review panel opens: ~47 ms
sequential vs ~24 ms concurrent, a saving of ~23 ms per diff.
Covers the merge-base, commit, and staged loaders, plus the unstaged path where
the working-tree read is independent of the index->HEAD chain. The unstaged
left chain itself stays sequential because its second step depends on the first.
The staged coalescing test asserted the sequential shape (one spawn, then the
next); it now pins the contract that actually matters — eight identical reads
still collapse to two spawns, one per side.
* test(perf): interleave the diff-blob benchmark arms
Running one strategy's whole batch before the other's lets cache warming, CPU
frequency drift, and background load correlate with the strategy being measured.
Alternate the arms per iteration, alternate which goes first, and report medians
so that drift stays common to both.
Also reject malformed env settings rather than truncating them — Number.parseInt
accepts "10foo" and 3.5.
Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff.
* fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default
Zellij and other multiplexers copy via OSC 52. Empty Pc is a valid XTerm
default for clipboard, but we rejected it, and the feature defaulted off so
copy silently failed inside Zellij. Accept empty Pc as clipboard, default the
setting on (query still blocked; size capped), and surface Zellij in settings.
Closes#10567
* fix(review): make the OSC 52 default actually reach existing installs
Review fixes for #10588:
- Persistence: profiles saved under the old off default persisted `false`,
which is indistinguishable from a real opt-out, so the default flip never
reached #10567's reporter. Added the repo's one-shot stamp
(terminalAllowOsc52ClipboardDefaultedOnForAllUsers) so unmigrated profiles
flip once and a later opt-out sticks.
- Replay: reattach/cold-restore re-writes recorded PTY bytes through the same
parser, so a stale `\e]52;c;...` silently clobbered the clipboard on every
restart. Gated behind isPaneReplaying via a new resolveOsc52ClipboardGate.
- Blocked toast latches once per renderer session and could be burned by a
pre-hydration read; it now fires only for a real opt-out.
- An empty Pd decoded to '' and, with the gate default-on, silently blanked
the clipboard. Now rejected as invalid.
- Localization: en.json is bundled and the catalog beats the code fallback,
so all three copy changes were inert. Resynced across five locales.
- Corrected the empty-Pc rationale: tmux (not Zellij) emits `\e]52;;<b64>`.
* test(terminal): cover the OSC 52 gate wiring and settings copy
Extracts createOsc52OscHandler so the replay/hydration gate wiring is
covered, not just the pure gate — dropping the isReplaying getter now
fails a test instead of passing silently.
Adds catalog assertions for the two OSC 52 settings strings. Only the
toast key was pinned, so the same inert-copy regression (code fallback
edited, bundled en.json not) could still ship for the settings pane.
* docs(settings): note that the OSC 52 default only covers new profiles
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): migrate the web settings store to the OSC 52 default-on flip
The default-on flip only reached the Electron store. The web/remote client
keeps its own settings in localStorage, so a profile that persisted the old
`false` there stayed opted out — the same bug the Electron migration fixed,
in the second store.
Extract the migration into shared/osc52-clipboard-settings.ts and call it
from both stores. Also coalesce OSC 52 writes onto a microtask so a hostile
chunk of ~15-byte sequences cannot fan out into a million clipboard writes,
and latch the blocked-write toast after it renders rather than before.
* feat(terminal): tell users when the OSC 52 flip overrides their opt-out
The default-on migration cannot distinguish a deliberate opt-out from a
profile that simply never touched the setting — both persisted `false` under
the old default. Flipping everyone is the only way to fix#10567 for existing
installs, but doing it silently reverses a security choice the user made.
Arm a one-shot notice at load when the migration overrides a persisted
`false`, on both settings stores, and show it once the renderer hydrates.
Profiles that never opted out are never notified.
* fix(terminal): clear the OSC 52 notice after it renders, not before
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): keep the web OSC 52 notice armed against an unmigrated host
The host store always projects osc52ClipboardDefaultOnNoticePending, so the
plain spread in the web client's runtime UI merge overwrote an arm raised by
its own localStorage settings migration — flipping the opt-out in silence.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): stop the OSC 52 notice overclaiming, and cover it
Round-3 review fixes:
- Rename the arming predicate to osc52ClipboardDefaultOnOverridesPersistedOff.
Both stores rewrite the whole settings object on every save, so every profile
saved under the old off default holds `false` — the deliberate-opt-out cohort
is not distinguishable on disk. Name, docs and test names now say so.
- Read settings before the UI snapshot in readLocalWebUIState: getStoredSettings()
arms the notice, so reading first snapshotted a pre-arm state that callers wrote
back, erasing an arm the stamp can never raise again.
- Give the notice toast a stable id; StrictMode re-runs the effect against the
same closure, so the early return cannot catch the second pass.
- Restore guardParserHandler parity in the coalescer microtask.
- Drop the unverified Zellij claim justifying all-selections routing; that routing
predates this branch and PRIMARY routing stays an open question.
- Cover the notice hook (order, single-fire, deep-link), the armed flag reaching
disk and surviving a clear, and pin the notice catalog to its code fallbacks.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin OSC 52 setting discovery by product name
The migration notice says to turn it off in Terminal settings, so searching
Zellij/Grok/tmux has to find it. Also note why the OSC 52 write-back clauses
stay despite an unrelated always-true clause in the same condition.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): consume the OSC 52 notice on close, and cover the guards it relies on
The notice was cleared the moment the toast was enqueued, so a quit inside its
15s window spent the profile's only warning on a launch where nothing was ever
seen — and the settings stamp means it can never re-arm. Clear on
onAutoClose/onDismiss instead, plus explicitly in the action handler, because
sonner's action path deletes the toast without firing onDismiss.
Also closes three coverage gaps a review found:
- ui.set must accept osc52ClipboardDefaultOnNoticePending. The update schema is
strict, so dropping the key rejects the whole call rather than stripping it,
and the renderer only logs that failure — every paired client would re-toast
forever with nothing red.
- the coalescer's try/catch and .catch had no test; the rejection case needs a
plain function because vi.fn tracks settled results and hides the leak.
- pin that every selection kind (including bare `p`) lands in the system
clipboard, so routing PRIMARY separately later is a deliberate break.
Co-authored-by: Orca <help@stably.ai>
* test(web): pin that ui.get arms the OSC 52 notice when it runs the migration
readLocalWebUIState reads settings before the UI blob so the migration's arm is
in place before the snapshot every caller writes back. Seeding localStorage
after install is what makes ui.get the first settings read, and therefore what
makes swapping those two lines fail.
Co-authored-by: Orca <help@stably.ai>
* test(store): cover the OSC 52 notice clear and its hydration
The clear sets local state before persisting so a rejected ui.set cannot leave
the toast re-firing for the rest of the session; losing the persist only re-arms
the notice next launch.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): state the real residual risk of default-on OSC 52
Three comment corrections from review:
- the safety note claimed exfil was the risk; queries are blocked, so it isn't.
The actual accepted risk is execute-on-paste: decoded text goes to the
clipboard verbatim, newlines included. Filtering here would break multi-line
TUI copies, which is the feature; bracketed paste is where that is handled,
and kitty/Ghostty take the same posture.
- the coalescer bounds a flood per parse yield, not overall.
- the replay gate reads at parse time while queued live bytes are drained
before the guard engages, so a copy racing a reattach is dropped silently.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): close the four OSC 52 gaps a full revert walked through
Mutation testing found four assertions that stayed green against the very
change they were written to pin.
The notice suite passed 8/9 against a complete revert to clear-at-enqueue:
`calls[0][1][callback]?.()` is a silent no-op when the option is absent, and
the call count was already satisfied by the enqueue-clear, so nothing
separated "cleared by this callback" from "cleared earlier". Assert the
option exists and the notice is unspent before invoking it.
The stable toast id was deletable with all 9 green despite the adjacent
comment calling it load-bearing for StrictMode. Pin it.
The blocked toast's latch-after-throw fix was unproven: both orderings pass
when `toast.info` succeeds. Only a throwing first call tells them apart.
Deleting the hook call in App.tsx silenced the desktop notice with every
suite green. Pin it alongside the static Toaster import, since sonner drops
a toast enqueued before any Toaster subscribes and never replays it.
Also retone the coalescer-latch comment, which claimed the reset ordering
was load-bearing on its own; the try/catch reaches the same end, so the
test binds the pair.
All four verified green->red by mutation, then restored.
* test(terminal): cover the OSC 52 notice and its guards
Add tests pinning the static Toaster mount required to prevent notice dropout (#10567), the stable toast ID deduping StrictMode double-invokes, that the notice stays unspent on toast throws, and that flush-latch guards prevent silent consumption across error boundaries.
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* fix(release-cut): gate an explicit RC against its own series
semver_gt compares through strip_pre(), so the explicit-version override
only ever checked the stable line: 1.4.156-rc.0 read as 1.4.156, cleared
a 1.4.155 stable, and republished an RC below what clients already run.
Anchor a prerelease request on highest_rc_for_base -- the same rc history
the kind path uses -- so the override can only advance the series.
Two sibling gaps in the same block:
- version_suffix was silently dropped when version was set, because the
append lives in the kind branch the override skips.
- the shape regex rejected X.Y.Z-rc.N.suffix, so a suffixed RC the rc
path can produce could never be re-cut explicitly.
* fix(release-cut): close both ends of the rc-number range the gate compares
The new explicit-rc gate compares with `[[ -le ]]`, i.e. bash machine-width
integers, and the author closed only the low end. Past INTMAX bash saturates,
so `version=1.4.156-rc.99999999999999999999` reads as "above the published
rc.3" and the gate falls open — then the tag it cuts pins
highest_rc_for_base at 1e20 for that base forever, and every later cut wraps
to a lower rc the fleet never updates to. Bound the rc number to nine digits.
Also reject leading zeros on an all-digit prerelease identifier. `npm version`
renormalizes rc.4.01 to rc.4.1 while the tag step keeps the literal input, so
the shipped package.json version and its own release tag name different
releases. The explicit path's embedded identifier now goes through the same
validator the kind path uses instead of only the shape regex.
* fix(release-cut): stop the refusal pointing minor/major RCs at the wrong series
kind=rc derives its base from bump(latest_stable, patch), so the remedy the
refusal suggested only works when the requested base *is* that next patch. A
1.5.0-rc.N series exists only because this override created it, so an operator
resuming a stuck 1.5.0-rc.2 was told to dispatch kind=rc, which would have cut
an unrelated 1.4.156-rc.4. Spell the condition out and give the fallback that
does work for a non-patch base.
Also correct the mechanism in the comment I added in 698c5beeaa: bash wraps
two's-complement, it does not saturate, which is why the hole is
value-dependent (rc.10000000000000000000 wraps negative and failed closed,
rc.99999999999999999999 wraps to 7766279631452241919 and sailed through).
And name both inputs in the suffix error, which now serves version_suffix and
the trailing identifier in version.
* fix(release-cut): count a suffixed RC from its commit subject, not just its tag
The new explicit-version gate only fails closed on a deleted tag because
highest_rc_for_base also reads `release: v<base>-rc.N` subjects. That fallback
did not parse the suffixed form: rcNumberFromTag accepts an optional
.identifier, rcNumberFromReleaseSubject did not, so `4.perf` failed its
`(\d+)(\s|$)` anchor and returned null.
So deleting a v1.4.156-rc.4.perf tag dropped the series back to rc.3, and an
explicit 1.4.156-rc.4 was waved through — below the rc.4.perf build
perf-channel clients already run. Same under-count already made kind=rc
recompute rc.4 over a deleted suffixed tag.
Mirror the tag form's optional identifier. Covered by a unit assertion and a
git-fixture test that both fail with this reverted.
* docs(release-cut): correct four operator-facing claims in the explicit path
All four are wording or consistency, no behavior change (harness: 26/26 before
and after, on bash 3.2 and bash 5.2).
- The trailing-identifier comment justified itself as preserving a shape that
"can never be re-cut through the override", but re-cutting a suffixed rc at
or below the series head is exactly what the new gate refuses. State what it
actually admits: a second spelling of version=X.Y.Z-rc.N + version_suffix.
- version_suffix's input description still said "rc kind only" after this PR
made it apply to an explicit bare X.Y.Z-rc.N.
- The suffix guard's own rc pattern was unbounded while the shape check twelve
lines up is bounded to nine digits; reuse the bounded one so a later edit to
either cannot silently drift.
- "which recovers the existing tag" was unconditional, but kind=rc recovery is
also gated on tag_matches_current_ref, so a tag cut from a ref main has moved
past advances to rc.N+1 instead.
* fix(skills): advance the release ledger at the cut so shipped revisions freeze
#10340 made the released-skill registry a function of the committed ledger
instead of a git tag walk, and #10460 reverted the cut step that advances that
ledger because it violated the #9119 contract (a version-only cut must not
regenerate or stage the content-addressed skill artifacts). Both were right;
the result is a ledger that never advances.
generate-skill-bundle-manifest.mjs:390 derives releasedCount solely from
release-mapping.json and :461 assigns a changed skill releaseRevision =
releasedCount + 1, while :518 protects only committedReleasedCounts[name] —
so index releasedCount is unprotected. A tag ships that tail revision, nothing
records it, and the next skill change rebuilds the same revision number over
different bytes. Installs carrying the shipped digest then match no snapshot
and degrade to unrecognized, which cannot be updated.
Restore the advance in a form the #9119 contract can keep enforcing:
--release now verifies that current-manifest.json and snapshot-registry.json
already match the ref being tagged, appends the mapping row, and writes only
release-mapping.json. The cut stages just that file, so it still cannot move a
content-addressed artifact — the failure #9119 guarded against — and now fails
loudly instead of recording a revision the tag does not ship.
The contract test is narrowed to match: it asserts the cut runs --release
(never --write) and stages exactly package.json and release-mapping.json.
* test(release-cut): close the staging bypasses the narrowed gate left open
The narrowed contract test anchored its `git add` scan to line start and
only inspected staged paths, so three ways to reintroduce #9119 stayed
green: a `git add` chained after `&&`, a write that never calls `git add`
at all, and `pnpm run generate:skill-bundle-manifest` — the package.json
alias for `--write`, which the hyphenated ban never matched. That last one
also passed the pre-#10460 assertions, so it was never covered.
Drop the anchor, require every `resources/skills` mention in the step to
be exactly what is staged, and ban the alias and `commit -a`. Comments are
stripped first so prose cannot trip a ban. Verified each bypass fails and
the real workflow passes.
* fix(release-cut): make the new provenance failure actionable to an operator
Verifying the content-addressed artifacts is the only new way the cut can
block, and it fails inside a step named "Bump package.json and tag" with a
lint-shaped message. That names the files and the command but not the two
things the operator needs: the regeneration has to land on main, and the
cut is safe to re-run afterwards. Say so.
Also pin down why assertReleasedHistoryPreserved takes the pre-append
mapping. It pairs with artifacts.releasedSnapshotCounts, which seeding
fixed before the row existed; handing it the post-append mapping makes
every cut throw "Released snapshot history is incomplete", which points
at tag fetching rather than the real cause. Nothing enforces the pairing.
* test(release-cut): gate the whole cut job, not just the bump step
Round-2 review defeated the previous gate twice, both proved by running
the full contract file green with #9119 reintroduced.
Every step in the cut job shares one workspace and one index, but the
contract test only inspected `Bump package.json and tag`. A step inserted
earlier could run --write and `git add resources/skills`, and the bump
step's own commit swept it into the version commit and the tag. Assert
job-wide instead: only the bump step may name the directory, and no step
may regenerate under either the flag or its package.json alias. That lives
in the generator suite because the contract file is at its max-lines cap.
Two regexes were also evadable. The mention scan required a trailing
slash, so a path held in a variable was invisible; it now matches the
directory itself. The `commit -a` ban matched nothing at all — `commit\s`
ate the only separator, so `-a`, `-am`, and `--all` all survived while
only a trailing `-a` was caught. `--allow-empty` stays allowed.
* fix(release-cut): assert the index, not the workflow text, before committing
Round-3 review defeated the job-wide grep three ways, each proved by
running both test files green with #9119 reintroduced into the tagged
commit: an `env:` block holding `--write` and `resources/skills`, a
composite action whose steps the workflow never spells out, and plain
shell concatenation (`root=resources; leaf=skills`).
Grepping shell source for path literals is inherently evadable, and the
previous fix only relocated round-2's variable-indirection hole one step
over. Move the invariant to where it cannot be dodged: immediately before
committing, the cut diffs its own index and refuses anything that is not
package.json or the release-mapping row. That does not care which step
staged what, or how the path was spelled.
The workflow grep stays as a cheap tripwire for literal spellings, now
paired with a positive assertion that the index guard exists and precedes
the commit — indirection cannot hide a missing guard. Mention matching
dedupes and trims quotes, since the guard names the row a second time.
* fix(release-cut): match the staged-path allowlist literally
`grep -vx` treats its patterns as regexes, so the `.` in `package.json`
matched any character: a staged `packageXjson` or a
`resources/skills/release-mappingXjson` was silently accepted by the
index guard. Verified both slip through `-vx` and are caught by `-vxF`.
Exercised the guard against a legitimate cut, an empty index, a staged
content-addressed artifact, paths containing a space and a non-ASCII
character (git quotes the latter, so it fails closed), and a staged
deletion. Only the two allowed paths pass.
* test(release-cut): assert the index guard aborts, not just that it exists
The positive assertion pinned the guard's shape and its position before
the commit, but not its effect: replacing `exit 1` with `:` left both
test files green while the cut logged the error and shipped the artifact
anyway. That is the same failure this whole gate keeps having — asserting
the shape of a defense rather than what it does.
Pin the abort too. Verified the neutered guard now fails the suite.
* test(release-cut): scope the abort check and catch clustered commit flags
Two holes in the guards this PR added, both in the same shape-not-effect
class the previous commit was meant to close.
The abort assertion's lazy match was not scoped to the guard's own block,
so it could borrow an `exit 1` from any later `if ... fi` in the step.
Degrading the guard to a warning while adding a plausible HEAD
precondition left every test green. Stop the match at the guard's `fi`.
The `commit -a` ban only matched when `a` led the flag cluster, so `-vam`,
`-va`, `-qam` and `-sam` all survived. That matters more than it looks:
`commit -a` stages at commit time, after the index guard has already
inspected a clean index, so it is the one way to defeat that guard. Match
`a` anywhere in a short-flag cluster; `--allow-empty` and `--amend` stay
allowed. Verified both mutants now fail.
* fix(release-cut): validate the commit, not the index, before tagging
The index guard asserted the wrong thing. `git commit` has a family of
forms that commit the working tree rather than the index — `-a`, `-i`,
`--only`, and a bare pathspec — so a rogue earlier step could leave
regenerated artifacts unstaged and any of those forms would carry them
into the tagged commit while the guard saw a clean index and passed.
Reproduced end to end: `git commit -i resources` put current-manifest.json
and snapshot-registry.json in the tag with all gates green, and
`--only resources` additionally dropped package.json from the tag.
Banning those flags one by one is the same enumeration game the earlier
rounds kept losing. Assert the outcome instead: after committing and
before tagging, diff-tree HEAD and refuse anything that is not
package.json or the release-mapping row. That is indifferent to which
step staged what and to how the commit was spelled.
Verified the whole family is now blocked (-i, --only, -a, -am, -vam,
pathspec, and an alias expanding to `commit -i`), that a stock commit and
an --allow-empty re-cut still pass, and that deleting, neutering,
un-anchoring, or relocating the guard each fails the suite.
* fix(release-cut): make the commit guard fail closed on a merge commit
Plain `git diff-tree` prints nothing for a merge commit, so the guard
would have passed silently instead of failing closed — the one direction
that matters on a release path. `-m --first-parent` reports the diff
against the first parent; verified byte-identical output for an ordinary
commit and still empty for the `--allow-empty` re-cut, so nothing else
changes. Not reachable today (nothing in the cut job creates a merge, and
npm version has no lifecycle hooks defined), but the failure mode is a
guard that looks like it ran.
Pin the flags in the assertion too, so neither dropping -m nor slipping in
a `--diff-filter` can weaken it without failing the suite.
* fix(skills): source released history from the committed ledger, not a tag walk
verify:skill-bundle-manifest rebuilt the entire released-skill history by
walking every local refs/tags/v* on each run and demanded byte-equality with
the committed artifacts. Output was therefore a function of (skill bytes x
local tag set x release timing), so any clone holding stray, deleted, or fork
tags the committed artifacts predate rebuilt a divergent registry and failed
lint. This was the 4th instance of one failure class (#8637 -> #9119 version
bumps -> #9778 new tags -> local tag drift), each patched with a new tolerance
rather than removing the tag coupling.
Fix: the committed snapshot-registry + release-mapping ARE the released history;
trust them instead of re-deriving from tags.
- releasedHistoryFromCommitted() seeds generation from the committed ledger,
dropping the floating unreleased tail (entries beyond what the mapping names).
verify and --write are now pure functions of working-tree bytes with zero tag
access. The tag walk survives only behind --rebuild-from-tags (disaster
recovery), off the everyday path.
- --release <version> + appendReleaseRow() perform the O(1) append of one
mapping row at release cut (dedupes vs the last row, strips the v-prefix) --
the single authoritative point where working-tree bytes become an immutable
released revision.
- release-cut.yml runs generate --release "$VERSION" before the release commit
(Node built-ins only, no install needed); pr.yml drops fetch-depth: 0 from the
lint job since verify no longer needs tag history.
Recognition is unaffected: the runtime uses knownSnapshots = registry.skills
(all entries, incl. the tail committed at PR-merge time), so a missing mapping
row only loses a version label, never recognition or the update nudge.
Trade-off: lint no longer cross-checks committed historical snapshots against
tags. A hand-edit to an old released entry is still caught by the runtime
manifest<->registry consistency check when the current manifest points at it,
and can be audited anytime with --rebuild-from-tags.
Verified: verify passes committed-sourced; --write is zero-diff (byte parity);
a planted stray v-tag no longer changes output; edit-stub -> --write -> --release
appends the correct single row; double --release is idempotent;
--rebuild-from-tags reproduces the committed artifacts. Generator tests 14 pass/
1 skip; runtime skill-bundle-artifacts + freshness-inventory 14 pass; bundled
skill guides verify passes.
* fix(skills): keep one release-mapping row per version on a re-cut
A cut that pushed the version bump to main but died before pushing the
tag is re-cut at the same version. If skills changed in between, the
second --release appended a duplicate row, and the stale one named
revisions that tag never ships — which verify-skill-update-roundtrip
then pairs with the tag's real bytes.
Overwrite the trailing row instead (the tag is absent, so that version
was never published). Refuse only when an earlier row claims the
version, which the cut workflow already rejects upstream, so this
cannot wedge a recovering cut.
The theme picker count row concatenates "Showing {count}" directly with
the " of {{value0}}" fragment. The ko/ja/zh translations dropped the
fragment's leading separator, so the shown and total counts fused
(e.g. Korean rendered "표시 중 3030 중" instead of "표시 중 30/30").
Restore a slash separator for the total-count fragment and the leading
space for the search-match fragment in ko/ja/zh, in both the runtime
catalogs and the key-override sources so catalog regeneration keeps the
repaired values. Add a regression test covering both fragments.
🤖 Generated with Claude Code
The Windows CLI shim is delivered via extraResources to
resources/bin/orca.cmd, beside the native resources/bin/orca.exe, and
resolves the launcher adjacent to itself (%SCRIPT_DIR%orca.exe) — which
works.
But nothing in `files` excluded resources/win32/, so its source copy was
also packed into app.asar and then extracted by asarUnpack:['resources/**']
to app.asar.unpacked/resources/win32/bin/orca.cmd. That duplicate has no
adjacent orca.exe, so invoking it fails with "Unable to locate the native
Orca CLI launcher", breaking orchestration skills that reach for the
unpacked shim.
Exclude the win32 shim source tree from app.asar so only the working
extraResources copy ships. Add a regression guard to the electron-builder
config test.
Closes#7351
* fix(claude-accounts): quote resolved claude path for Windows shell spawn
runClaudeCommand spawns the resolved claude command with shell:true on
Windows, but spawn concatenates the command into the cmd.exe line without
quoting. When the CLI resolves to a path containing spaces (e.g.
C:\Users\First Last\AppData\Roaming\npm\claude.cmd), cmd.exe splits at the
first space and account add fails with:
'C:\Users\First' is not recognized as an internal or external command
Quote the command the same way claude-pty.ts and quoteWindowsCmdArg
already do for other Windows spawns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(claude-accounts): own Windows cmd invocation
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
electron-builder 26 dropped the app-builder-bin Go binary, so the
signed-installer staging step failed with 'node_modules/app-builder-bin/
win/x64/app-builder.exe is not recognized'. Blockmap generation now lives
in app-builder-lib's pure-JS buildBlockMap; call it through a small script
in both the release-cut and signing-rehearsal workflows.
Co-authored-by: Orca <help@stably.ai>
* fix(linux): restore Ubuntu 20.04 launch by pinning node-pty glibc symbols (#9902)
The bundled node-pty pty.node is compiled from source in release CI on
ubuntu-latest (glibc 2.39). glibc's 2.32-2.34 libpthread/libutil merge
relocated openpty/forkpty (GLIBC_2.34) and pthread_sigmask (GLIBC_2.32)
into libc under new symbol versions, so the from-source build bound to
versions absent on Ubuntu 20.04 (glibc 2.31). The main process imports
node-pty at startup, so the app crashed on launch. pty.node is the sole
blocker (Electron needs GLIBC_2.25; other native modules <= 2.17).
- Patch node-pty: a .symver shim pins the 3 symbols to their pre-merge
version (GLIBC_2.2.5 x64 / GLIBC_2.17 arm64), and Linux-only ldflags
force libutil.so.1/libpthread.so.0 back into DT_NEEDED. Guarded to
Linux; macOS/Windows untouched.
- Add a packaging gate (verify-linux-glibc-floor.cjs, afterPack): reads
each bundled native binary's objdump -p version needs and fails the
Linux build if any strong GLIBC_/GLIBCXX_/CXXABI_ node exceeds stock
Ubuntu 20.04 (glibc 2.31 / GLIBCXX_3.4.28 / CXXABI_1.3.12). Catches
GLIBC_ABI_DT_RELR, rejects GLIBC_PRIVATE, skips weak needs, fail-closed.
- Docs + tests; the lazy sherpa-onnx speech prebuilt (GLIBCXX_3.4.29,
never loaded at launch) is a documented libstdc++-floor exemption.
* fix(linux): assert DT_NEEDED provider deps in the glibc-floor gate
Harden the packaging gate (flagged in adversarial re-eval): the version-floor
check alone can false-pass if the patch's forced `-l:libutil.so.1` ever silently
drops — the pinned openpty@GLIBC_2.2.5 still resolves from libc's compat alias at
build time, but fails to load on Ubuntu 20.04 where openpty/forkpty live only in
libutil. The gate now also asserts that any binary importing openpty/forkpty
keeps libutil.so.1 in DT_NEEDED. Validated on a real symver-pinned .so with
libutil dropped (now fails) vs. present (passes). Documents the recommended
real-host smoke-test follow-up.