Commit Graph
8668 Commits
Author SHA1 Message Date
Brennan Benson 07b207c5fa refactor(worktree): put the retired-name cache rules on one implementation
The desktop and mobile hooks that fetch retired names had already drifted
four ways. The transports genuinely differ (IPC vs RPC), but the caching
rules must not, and mobile's copy reset to [] on any error -- which
un-retires every name for the rest of the sheet session, the one outcome
retirement exists to prevent.

Moves the rules into src/shared/worktree/retired-name-cache: response
normalization, the never-leak-across-repos rule, and the hold-previous-on-
failure rule. Pure, no React, because src/shared is on the main process's
import graph. Each platform keeps its own transport and effect.

Mobile moves up to desktop's behavior: it now holds the previous answer
through a failed refresh, and refetches when the workspace list changes
instead of never refetching after mount.

Also drops the unused `loading` return. Neither platform consumed it; its
only consumer was the Create-button gate reviewed out earlier, and removing
it makes that regression unexpressible.
2026-08-13 22:58:33 -07:00
Brennan Benson 3d3165ed78 Merge remote-tracking branch 'origin/main' into brennanb2025/retire-generated-workspace-names
# Conflicts:
#	src/preload/api-types.ts
#	src/shared/types.ts
2026-08-13 22:23:20 -07:00
NeilandOrca 5a6837fce4 refactor(store): unify the duplicated catalog equality and identity-key helpers (#13804)
* refactor(store): unify the catalog structural-equality walks

Three near-identical structural deep-equality walks had landed independently in
the same window: areValuesEqual (#13744, repo-identity-reconcile.ts),
areCatalogEntriesEqual (#13770, repos.ts — already folded into the first on this
branch's base) and catalogValuesEqual (#13662,
worktree-catalog-reconciliation.ts). All three walk plain records and arrays and
fall back to reference equality for anything exotic.

They are not interchangeable. Two axes genuinely differ, and each caller depends
on its own side:

- Own-key set. #13744/#13770 require equal own-key counts plus hasOwnProperty,
  so an absent key differs from a key present and holding `undefined`. #13662
  compares the union of both sides' keys, so those are equal. The strict side is
  load-bearing: the repo/project merges branch on
  `'localWindowsRuntimePreference' in project` (repos-project-runtime.test.ts
  "clears stale local runtime preferences"), and projects are now reconciled
  with this comparator. The loose side is test-pinned by
  worktree-catalog-reconciliation.test.ts "reuses rows with equivalent nested
  catalog data", where a locally built row carries `optional: undefined` that
  the host omits.
- Leaf comparison. #13744/#13770 use `===` (NaN never equal, 0 equals -0);
  #13662 uses `Object.is` (the reverse).

So instead of picking a winner, src/shared/structural-value-equality.ts holds
one walk parameterised by those two axes and exports the two policies as
`structuralValuesEqual` and `structuralValuesEqualIgnoringUndefined`. Every
caller keeps its exact current semantics; the ~40 duplicated lines and the
silent divergence go away. src/shared/persisted-ui-equality.ts (a fourth copy
with a Set branch and no plain-object guard) is deliberately left alone: it
gates a disk write in main with no direct test coverage.

Also folded, all provably behaviour-identical:

- The `${hostId}\0${repoId}` composite key had three copies
  (getRepoHostIdentityForParts, repoOwnerKey, getEntryKey) that must agree or
  repos silently stop reconciling. Moved to src/shared/repo-host-identity.ts
  because one of them lives in src/shared; the renderer module re-exports it.
- mergeFetchedReposForHost's hand-inlined upsert loop now calls mergeByIdentity.
  mergeByIdentity additionally skips replacing a structurally equal row, which
  cannot change the result here: reconcileFetchedRepos runs immediately after
  over the same identities in the same order and restores exactly those rows.
- Renamed repos.ts's `catalogRowsUnchanged` to `arrayElementsUnchanged`. It is a
  pure element-identity compare, two files away from
  `catalogRowsEqual`, which is a full structural compare.

src/shared/structural-value-equality.test.ts pins both policies over arrays,
nested records, null-prototype records, absent-vs-undefined keys, symbol keys,
and non-plain objects (Date/Map/Set/class) falling back to reference equality.

* fix(store): keep merged sourceRepoIds order host-independent

Prefixing the cross-host remainder made a cross-host project's sourceRepoIds
order a function of the refreshing host, so the projects reconcile never reused
the row. Also pins the repo-derived host-id contribution the new per-project
slice feeds the host-id resolvers.

Co-authored-by: Orca <help@stably.ai>

* refactor(store): migrate call sites that landed after this branch

github.ts and ai-vault-session-identity.ts began using areValuesEqual on main
while this branch was stale, and repo-identity-reconcile's record reconciler
still called its own deleted walker. All three now use structuralValuesEqual;
reuseEqualCatalogRows keeps its duplicate-id cap and calls the ignoring-undefined
variant, which is the key-union semantics catalogValuesEqual had.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-13 22:15:20 -07:00
Jinjing d6e6b9e303 fix(native-chat): keep caller abort ahead of WSL gate refusal (#14445)
Why: a hook-path or scan refusal that races the caller's abort was
converted into a WSL unavailability error, so cancellation looked like
a stalled distro.
2026-08-13 22:09:23 -07:00
Jinjing eb9ac440aa Revert "Center newly opened tabs at end, keep close controls visible" (#14450) 2026-08-13 22:07:53 -07:00
Neil aecce221bd fix(test): deflake relay exec env, project boundary, and speech resume tests (#14446)
Three test-suite problems, all root-caused in the tests rather than in
production behavior.

1. src/relay/agent-exec-handler.test.ts (real failure, not a flake)

The two spawn-argument assertions failed with "Number of calls: 1" — spawn
ran, but the env differed. Cause: both assert
`expect.objectContaining({ ...process.env, ... })`, which demands that every
ambient variable reach the child verbatim. #7986 (1a6abc87d1) changed both
sides at once: it rewrote the assertion from `env: process.env` to that
objectContaining form, and in the same commit made the handler apply
`applyTerminalGitCredentialPromptGuard`, which appends its own entries to
Git's indexed-config protocol (GIT_CONFIG_COUNT / KEY_n / VALUE_n).

So whenever the test runner's own environment already carries that protocol —
exactly what Orca exports into its agent terminals — the snapshot expects
GIT_CONFIG_COUNT=2 while the correctly guarded child gets 4. The test passes
on a bare CI shell and fails when run from a guarded terminal.

The implementation is right: appending the guard after the caller's config is
the documented contract, and "guards wrapped agents after atomically replacing
inherited indexed config" already covers it. Fixed the test instead, by
clearing the guard-owned keys (GIT_CONFIG_* protocol and WSLENV) from the
ambient env for the duration of the suite and restoring them afterwards, so
the passthrough baseline is deterministic. No assertion was weakened or
removed.

2. project-view-wrapper-source-context-boundary.test.ts (flake: 30s timeout)

`buildProjectWorkItem` is a pure function, but it lived in
ProjectViewWrapper.tsx, so importing it pulled in the store, sonner, lucide,
and the whole UI kit — ~8.8s of transform and module evaluation for one
assertion, which tipped past the 30s limit under parallel load.

Extracted it to project-work-item.ts (its only dependency is
githubProjectHost) and pointed the test there. Both test cases are unchanged.
Also dropped the now-unneeded happy-dom environment, since nothing in the file
touches the DOM any more. 9.15s -> 0.12s.

3. model-manager-download-resume.test.ts (flake: 30s timeout)

"bounds a server that advances by pathologically tiny segments forever"
drives the loop to the MAX_TOTAL_DOWNLOAD_REQUESTS ceiling of 4096. Each
iteration did a real writeFileSync plus two statSync calls through
getPartialDownloadBytes — ~12k synchronous filesystem syscalls in a tight
loop. Fast on an idle disk, but it serializes against every other vitest
worker on a loaded machine, which is what blew the per-test timeout.

Stubbed getPartialDownloadBytes to read the byte counter the test already
maintains, so the loop is pure CPU. The file was only ever a stand-in for that
counter. Ceiling and rejection assertions are unchanged: 332ms -> 15ms.

The two remaining ~1.1s cases in that file spend their time in the real 1s
retry backoff around real stream and file-write plumbing; they are left on
real timers because faking them would mean faking the transport too, and 1.1s
leaves ample headroom.
2026-08-13 21:51:55 -07:00
Jinjing 25abb9368d fix(stats): wrap usage breakdown model names (#14065) 2026-08-13 21:25:54 -07:00
Neil 4221f8d429 refactor(preload): split the preload contract into per-domain api modules (#14403)
`src/preload/api-types.ts` was 3,752 raw lines (3,533 counted, 11.8x the
300-line budget) behind an `eslint-disable max-lines`. Almost all of it was a
single `PreloadApi` object type whose ~83 namespace properties were declared
inline, so any IPC surface change meant editing one 2,600-line type.

Give each namespace a named type in its own module under `src/preload/api/`
(`pty-api.ts`, `filesystem-api.ts`, `github-pull-request-api.ts`, ...) and
recompose `PreloadApi` from those names. `api-types.ts` keeps the `declare
global` Window augmentation and re-exports every moved name, so all 52 import
sites are untouched.

Two shapes needed care to stay type-identical rather than merely compatible:

- Three keys (`gh`, `git`, `ui`) are composed from two modules each. A plain
  intersection is NOT identical to the original flat object literal, so those
  use a `Merged<T>` mapped type; a negative control confirmed that dropping it
  fails the parity assertion.
- Keys whose module groups several namespaces use indexed access
  (`fs: FilesystemApi['fs']`) to preserve exact identity and source order.

`config/tsconfig.web.json` and `tsconfig.tc.web.json` enumerate files by path,
so they need `src/preload/api/**/*` alongside the existing `api-types.ts` seed
or the web projects fail TS6307.

Verified by exact type identity, not assignability: 41 assertions of the form
`Equals<Now.X, Before.X>` against a frozen pre-split snapshot, covering every
exported name, plus a per-key pass over all 83 `PreloadApi` keys. All three
projects typecheck clean with those assertions active.

Verification note: these tsconfigs are `composite: true`, and `tsc --noEmit`
will reuse a stale `.tsbuildinfo` and report clean for a state that genuinely
fails. Every result above was produced after deleting the buildinfo, including
a negative control confirming the gate still fails on deliberate drift.

Drops the `max-lines` bypass and its baseline entry (ratchet 346 -> 345).
2026-08-13 20:52:04 -07:00
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00
Neil 070572bfd7 refactor(shared): split shared/types.ts into per-domain type modules (#14397)
`src/shared/types.ts` was 3,981 raw lines (2,825 counted, 9.4x the 300-line
budget) behind an `eslint-disable max-lines`, and is imported by 2,092 files —
the single widest contract surface in the repo.

Move all 320 top-level declarations into 46 per-domain modules
(`repo-types.ts`, `worktree-types.ts`, `github-pr-types.ts`, ...) and reduce
`types.ts` to an explicit re-export barrel, so the 2,092 import sites are
untouched.

`HostSettingOverrides` moves into the pre-existing `host-setting-overrides.ts`
alongside the accessors that operate on it, which also removes that module's
circular import back into `types.ts`.

Named re-exports only, never `export type *`: with star re-exports a name
exported by two modules is silently dropped, which would surface as a confusing
"has no exported member" at a random call site.

Verified lossless mechanically, not by inspection:
- export parity — the module's resolved export set through the TS checker is
  identical before and after (396 names, no additions, no removals)
- declaration parity — all 320 declarations compare character-identical modulo
  comments and whitespace, so no optionality, union order, or generic
  parameter drifted
- `tsc --noEmit` green on the node, cli, and web projects
- `oxfmt --write` is byte-identical, so the barrel is format-stable

Drops the `max-lines` bypass and its baseline entry (ratchet 346 -> 345).
2026-08-13 20:34:31 -07:00
Neil 400321edcd fix(workspaces): gate the GitHub palette number match on repo remote identity (#14413)
* fix(workspaces): gate the GitHub palette number match on repo identity

`repoMatchesGitHubSlug` returned the permissive `'unknown'` whenever the repo
displayName was not in `owner/repo` form and no upstream metadata existed — the
common basename-named non-fork case. The caller only rejects on `false`, so a
pasted issue/PR URL could activate a workspace in a different repo that happened
to share the number, since issue/PR numbers are per-repo.

Mirror the GitLab gate from #14381: fall back to the probed
`gitRemoteIdentity.canonicalKey` before giving up, comparing host and owner/repo
after normalizing port, `www.`, and case. An `upstream`-derived identity stays
`'unknown'` because `deriveGitRemoteIdentity` ranks `upstream` above `origin`, so
a fork's own origin is invisible and rejecting would drop URLs from the fork the
user actually checked out.

The canonicalKey compare runs after the displayName branch: displayName is
compared host-agnostically, so mirrors and host aliases of the same owner/repo
keep matching as they do today, and the probed remote only fills in where no
name evidence exists.

Refs STA-4237

* fix(workspaces): keep SSH host aliases matching in the palette identity gate

`git remote -v` reports ssh.github.com, www., and ~/.ssh/config `Host` aliases
verbatim, so comparing a probed canonicalKey against a pasted URL host rejected
legitimate GitHub/GitLab remotes. Normalize the alias hosts both sides can fold
offline, and downgrade a host-only mismatch to 'unknown' when the probed host is
dotless (an unexpandable OpenSSH alias); dotted hosts like ghe.example.com still
lose. Lifts the GitHub host normalizer into shared instead of a third copy.

* fix(repos): keep the www host fold out of the derived project identity

getProjectIdentityKey feeds the persisted Project id, so folding www. there
re-keyed existing projects on upgrade and dropped localWindowsRuntimePreference.
Restrict the fold to the palette's URL-vs-remote comparison, and pin the derived
id for a www. remote so it cannot drift silently again.
2026-08-13 20:28:32 -07:00
Neil 17690d49ea fix(repos): re-probe git remote identity so a stale snapshot stops misjudging identity gates (#14414)
* fix(repo-identity): re-probe resolved git remote identities on a long TTL

A resolved gitRemoteIdentity was written once and frozen for the life of the
repo record, so adding an `upstream` remote later — or a project rename or
transfer — left identity gates judging against the path the repo had when it
was added. Re-probe resolved repos on a 6h TTL, seeded 5 minutes after a repo
is first seen in a process and capped at 4 refreshes per sweep so a restart
cannot fan out a subprocess per repo. Only a successful probe that yields a
different canonicalKey overwrites; failures and no-remote answers leave the
existing identity alone.

Also explain why the worktree-scan admin fingerprint timeout deliberately
exceeds its caller budget, and log when that probe expires — expiry was
silent and indistinguishable from "fingerprint unavailable".

Refs STA-4247

* fix(projects): carry project state across derived project id changes

A project id is derived from repo identity, so a remote re-probe (or a
repo:->git:->github: promotion) rewrites it. The compatibility merge matched
prior rows by id only, dropping the user's localWindowsRuntimePreference and
leaving a ghost project row that independent host setups still pointed at.

Both merge sites now fall back to the prior row whose sourceRepoIds overlap and
re-point independent setups at the surviving project.
2026-08-13 20:28:29 -07:00
Neil d3381e550c fix(terminal): stop leaking raw PTY lifecycle tokens into the error toast (#14415)
* fix(terminal): stop raw PTY-not-found and session-expired tokens reaching the error toast

A reattach the host answers "no such session" for surfaced its wire token
verbatim — `SSH_SESSION_EXPIRED: orca:<conn>@@pty-N` or the relay's raw
`PTY "..." not found` — including the internal PTY id, and invited the user
to file an issue for an ordinary lifecycle event.

Humanize both in the toast's existing daemon-boundary seam and mark them
explained so the issue link is suppressed. The copy stays silent on whether
the remote shell died: absence from the host is not proof of exit.

Refs STA-4238

* style(terminal): apply oxfmt to the toast humanization tests

* fix(terminal): treat the humanized session copy as literal replacement text
2026-08-13 20:28:25 -07:00
Langning ZhangandJinwoo-H 3246b73add Forward Unix launcher signals to orca serve supervisor (#14071)
* fix(cli): forward Unix launcher signals to serve

* test(cli): retry incomplete listener state writes

* test(cli): cover both Unix launcher termination signals

* test(cli): cover macOS launcher signal oracle

* refactor(cli): keep Unix launcher exec atomic

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
2026-08-13 20:05:49 -07:00
Neil 8c3a1197d5 test(terminal): fix ordering pins that pass when the needle is absent (#14409)
Five pins compared indexOf positions without asserting presence, so a missing needle returned -1 and the assertion passed. Deleting the legacy-shim-dir capture in both Windows wrappers stayed green that way, disabling legacy-dir PATH removal. All five now route through a helper that asserts both operands exist first.

Adds coverage for four properties proven load-bearing by execution: the path_entry_kept guard (without it an empty cleaned PATH resolves a cwd-local git), the cleaned PATH on exec, and multi-separator matching in the shared path matcher.
2026-08-13 19:19:30 -07:00
Neil 0a0ae974d4 fix(workspaces): gate the GitLab palette number match on repo identity (#14381)
* fix(workspaces): gate the GitLab palette number match on repo identity

After a full GitLab identity compare failed, matching fell through to an
unguarded iid compare, so a pasted URL could activate a workspace from another
project or host. GitLab iids are per-project and start at 1, so small-number
collisions across projects are the norm.

Mirror the GitHub shape rather than rejecting outright: gate the number-only
fallback on a tri-state repo identity check that stays permissive when identity
is unresolvable, so forks and host aliases keep matching. A stored URL that
parses to a different project with the same type and number is a direct
contradiction and is rejected even when identity is unknown.

Refs STA-4155

* fix(workspaces): keep the GitLab repo gate permissive for fork checkouts

deriveGitRemoteIdentity keeps a single remote and ranks `upstream` above
`origin`, so a fork checkout resolves to the upstream project and the fork's
own origin is invisible to the gate. Rejecting on that mismatch dropped MR
URLs from the fork the user actually checked out — a false negative the
tri-state was meant to prevent. Treat an upstream-derived identity as unknown.

Refs STA-4155

* docs(workspaces): note that the GitLab repo identity is a one-shot snapshot
2026-08-13 18:46:36 -07:00
Neil 478bfe111e fix(worktree-scan): stop a stalled admin probe from poisoning repo refresh (#14379)
The scan cache stored the Git-admin fingerprint as an unsettled promise, so a
readdir/stat that never returns (hard NFS/SMB mount, dead sshfs, wedged cloud
FileProvider) left every later refresh awaiting it. The in-flight entry was
never cleared, so the repo fell back to persisted rows indefinitely — and since
computeResolvedWorktrees awaits all repos together, one wedged repo added 5s to
every snapshot for all of them.

Cache the settled value instead, filled by an identity-guarded writeback, and
bound the one branch that actually awaits the probe. withTimeout cannot cancel
a readdir, so an outstanding-probe guard keeps a wedged mount from issuing a
fresh probe every refresh and pinning every libuv fs thread.

Refs STA-4171
2026-08-13 18:44:06 -07:00
Neil e20e76a9f4 fix(orchestration): release federation ack checkpoints once a dispatch settles (#14380)
Checkpoints were inserted per synced federated dispatch and never removed; the
only eviction dropped the whole map, and none of its three call sites fire in
normal operation. A long-running federated coordinator retained one small
object per dispatch for the process lifetime.

Prune from the existing syncOrchestrationFederatedDispatch finally, which is
the one hook covering all five paths that create a checkpoint — including the
two RPCs that sync an already-terminal dispatch with no timer to prune after.

Refs STA-4014
2026-08-13 18:43:56 -07:00
Jinjing b19d99bd00 Center newly opened tabs at end, keep close controls visible (#14314)
* Center newly opened tabs at end, keep close controls visible

- Add end padding to tab strip to preserve close button visibility against fade
- Center scroll position for newly revealed end tabs, avoiding hard scroll-to-end
- Only pin-to-end for new tabs that are also active; restored mid-strip tabs stay in place

* test(tab-scroll): clarify when tabs center vs settle at end

Add lastTabGeometry helper to correctly derive scrollWidth from the pad policy. Update test expectations to document that center requests only land centered when the strip is within 2x the inset of the last tab's width; wider gaps settle at the end with inset.

* fix(tab-bar): sync active tab id ref after render

React Doctor rejects mutating refs during render. Keep the latest
active tab id in a layout effect so overflow navigation still sees
it without an impure render write.
2026-08-13 18:28:41 -07:00
Brennan Benson 58e8c7d481 fix(worktree): hold retired names across a refresh instead of blanking
refreshKey changes on every workspace-list mutation, so create-multiple
refetches after each create and the hook returned an empty list until the
refetch landed — precisely the window in which resetForNextCreate clears the
name field and a fresh suggestion is drawn. Keep the previous answer while
revalidating and reset only when the repo changes; a failed refresh keeps what
was already loaded rather than un-retiring everything.

Also makes the returned array referentially stable, so the suggestion memo
downstream stops rerunning on every refetch.
2026-08-13 18:24:41 -07:00
Brennan Benson 348bf5d472 perf(worktree): keep the retirement backfill off the blocking WSL probe
The backfill runs on composer repo-select, not just at create time, and it
derived the probe path synchronously — which for a WSL repo with a mirrored
workspace dir reaches getWslHome and its blocking execFileSync('wsl.exe').
A stopped distro froze the main process for up to 5s on composer open.

Adds an async twin of computeWorktreePath and uses it for the probe. Resolving
the home there also warms the shared cache, so later sync callers are free.

Also stops memoizing the collision key when the WSL home is still unresolved:
only the success path is cached upstream, so caching the fallback namespace
would strand the repo there for the rest of the session.
2026-08-13 18:24:31 -07:00
Brennan Benson 5052f1ccfa fix(worktree): retire names that took more than one collision suffix
isAutoGeneratedCreatureBranchName strips exactly one trailing -N, which is
right for auto-rename eligibility but wrong here. Once the pool is spent the
suggester emits nautilus-2, and a collision on that yields nautilus-2-3 —
which a single strip leaves as nautilus-2, not a pool name, so retirement
no-opped at exactly the tier where every base name is already gone. Strip
repeated suffixes locally rather than moving the auto-rename predicate.
2026-08-13 18:24:30 -07:00
Brennan Benson b87360f6c0 fix(worktree): send name provenance from the web runtime client
This client hand-enumerates worktree.create params, so the new optional field
was silently dropped and typecheck could not see it. On web and paired-desktop
the host therefore never received it: generated names were never retired, and
the host-side skip that backstops a stale suggestion was disabled too. The same
client does fetch retired names for suggestions, so it was filtering against a
registry nothing ever wrote to.

The test asserts both directions, and fails without the fix.
2026-08-13 18:24:18 -07:00
Neil 94df72d9eb ci(windows): cover the worktree admin fingerprint on the Windows runner (#14378)
The fingerprint gate added in #14207 reads Git's administrative layout directly -- `.git` as a file or directory, `commondir`, and per-worktree `HEAD`, `gitdir`, and `locked` -- instead of shelling out to `git worktree list`. That makes it depend on Windows path resolution, CRLF inside those files, and whether `worktree move`/`lock` and deleting a live checkout behave as they do on POSIX.

PR CI runs the vitest suite on ubuntu-latest only, so none of that was exercised. Both suites were verified by hand on a real Windows host (Git 2.55.0.windows.3, Node 24.18.0) and pass 25/25, but nothing kept them passing.

Add them to the existing curated `Test Windows-specific boundaries` step rather than standing up a new job: the `package (windows)` job already checks out and installs dependencies, so this costs only the tests themselves.
2026-08-13 18:21:48 -07:00
Neil 444d30180d test(terminal): pin the wrapper security properties that survived mutation (#14394)
* test(terminal): pin the seven wrapper security properties that survived mutation

The PowerShell drive-relative pin was a prefix that also matched the insecure regex, so re-accepting C:foo left the suite green. Now asserts the full pattern.

Also pins all four PowerShell TrimEnd sites, the fallback wrapper-dir skip, the cmd legacy-dir trailing-separator strip, and C:/-style drive acceptance, plus a behavioural test for the POSIX legacy-dir reject with a distinct ORCA_ATTRIBUTION_SHIM_DIR. Each verified by reverting the property and confirming the suite fails.

* test(terminal): cover the trailing-separator scrub in the path matcher

isLegacyTerminalShimPathEntry sits outside the two generator modules, so the mutation campaign never re-ran it. Dropping its trailing-separator strip survived the suite: a PATH entry spelled with one would not match, leaving the legacy shim directory on the spawned PATH and the wrapper reachable. Mutation-verified on both the POSIX and Windows spellings.
2026-08-13 18:20:33 -07:00
Neil 517a2b3da6 perf(worktrees): bound the duplicate-id scan in reuseEqualCatalogRows (#14271)
* perf(worktrees): bound the duplicate-id scan in reuseEqualCatalogRows

Rows sharing an id are scanned linearly with a deep compare each, so a bucket
of k duplicates costs O(k^2). Both callers key on ids that are unique by
construction, so this is a bound on damage rather than a live fix.

Cap the scan instead of adding a second index: reuse is only an optimization,
so a missed match yields a new object identity, never a wrong row. A fingerprint
index would buy a little more reuse in a case nothing reaches, at the cost of a
second equality implementation that must stay in step with catalogValuesEqual
with no automated guard.

Worst-case duplicate bucket, no matches: k=1000 134ms -> 1.2ms, k=2000 541ms ->
2.2ms. Unique-id path unchanged (2000 rows: 0.86ms both).

* docs(worktrees): lead the duplicate-id cap comment with its reachability

A reader hitting MAX_DUPLICATE_ID_SCAN should learn first that no caller
produces duplicate ids today, so the cap reads as bounding future damage rather
than fixing something live.
2026-08-13 18:14:25 -07:00
Neil eb22e497bb Revert "fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes" (#14395) 2026-08-13 18:11:33 -07:00
Brennan Benson f583faebc7 docs(worktree): spell out why the retirement bound sits above the pool
Names the trap directly: the neighbouring 50/200 bounds cap histories, so
lowering this one to match them would silently start reissuing names whose
agent state is still on disk. Also states that oldest-first eviction is a
deliberate least-bad choice rather than a neutral one.
2026-08-13 18:01:10 -07:00
Brennan Benson 9234cb4055 fix(worktree): key retirement by repo id and prune it with the repo
Reverts the collision-key storage key. It was a function of workspaceDir,
nestWorkspaces, worktreeBasePath and repo.path, so toggling any one of those
orphaned every retirement for every affected repo at once — trading a rare
churn (remove/re-add) for a common one. The read path already unions by cwd
namespace at query time, so cross-repo sharing never depended on the storage
key.

Instead, address the growth and orphaning directly:
- Drop the registry in removeProject, and in removeProjectForHost once the last
  host's copy of the repo id is gone, alongside the sparse-preset deletes that
  already follow this convention.
- Bound each repo's registry. The cap sits far above the 552-name pool because
  evicting inside it would reissue a name whose agent state is still on disk;
  only -2/-3 tier accumulation can ever reach it.
- Carry retirements through profile transfer, re-keyed to the destination repo
  id and dropped from the source, mirroring sparsePresetsByRepo.

Separately, fix the backfill merge: the scan promise is cached per cwd
namespace, but it closed over the first repo id that triggered it, so a second
repo in the same namespace received nothing. The scan stays shared; the merge
moves out of the cached promise and runs for whichever repo asked.

Local repos re-seed on re-add through that backfill. SSH repos do not — the
scan cannot see the execution host — which is now stated in the module.
2026-08-13 17:59:00 -07:00
Brennan Benson 9d8ce5c6f2 Merge remote-tracking branch 'origin/main' into brennanb2025/retire-generated-workspace-names
# Conflicts:
#	src/renderer/src/store/slices/worktree-helpers.ts
2026-08-13 17:49:34 -07:00
Brennan Benson ef06e49a64 fix(worktree): send name provenance and stop gating Create on the fetch
Desktop and mobile now mark a create as generated-name only when the user
typed nothing and the composer fell back to the suggestion, so the host knows
which names it may retire.

Remove the retired-names loading gate from every create path. The host already
skips retired candidates before doing any git work, so the client gate bought
nothing while it could disable Create for the length of a full mobile
reconnect ladder (the wait had no timeout) and blank the desktop button
between queued creates. The suggestion still waits; the button never does.

Also make the web client call worktree.listRetiredNames instead of hardcoding
an empty list — the method is registered and mobile-allowlisted, so the
comment claiming no wire call existed was wrong — and filter the mobile
response to strings so a malformed row cannot throw during normalization.
2026-08-13 17:43:56 -07:00
Brennan Benson 5aef758fb0 fix(worktree): retire only generated names, keyed by cwd namespace
Two problems in the host-side registry.

Retirement fired for every create, including names the user typed. The
creature pool contains ordinary words — orca, runner, sole, molly, oscar — so
typing a retired 'nautilus' silently produced directory and branch
'nautilus-2' and burned the name for good. Creates now carry an explicit
nameWasGenerated flag; both the skip and the retire are gated on it, and it
defaults to false so CLI and automation callers are unaffected.

The registry was keyed by repo id, but both readers already discarded the id
and unioned by the cwd collision key, because the collision this prevents is
on the path. Keying by that namespace directly fixes several things at once:
entries no longer orphan when a repo is removed, remove/re-add no longer loses
every retirement for an unchanged path, the missing removeProject prune is
moot, and the backfill promise no longer merges into only the first repo id it
saw. The feature is unreleased, so no migration is needed.

Also:
- Memoize the collision key. It runs computeWorktreePath, which for a WSL repo
  is a blocking execFileSync('wsl.exe') whose failure path is uncached, and
  the previous code recomputed it once per repo on every create and every
  listRetiredNames call.
- Drop retiredNamesByRepo from the worktree list result. It had no readers and
  leaked onto 'orca worktree list --json', and its awaited backfill sat on CLI
  selector resolution. The dedicated listRetiredNames RPC keeps its consumers.
- Make the three RuntimeStore methods required. RuntimeStore is file-private
  with two constructors, so the 'older embedders' the optionality protected do
  not exist, and the optional chain silently returned no retirements.
- Revert the unrelated forceDeleteBranch rewrite, and make room under the
  file's line budget by extracting the create-args mapping instead.
2026-08-13 17:43:31 -07:00
Brennan Benson 54afdb36ac fix(worktree): make the retirement seed scan actually match buckets
The bucket encoder collapsed runs of non-alphanumerics while the real one
emits a dash per character, so every dot-path bucket missed and the Windows
default workspace root (C:\...) matched nothing at all. Reuse the shared
encoder and its boundary check, which also stops a repo absorbing a sibling
whose path merely shares its prefix.

Also:
- Derive the workspace leaf by stripping the known encoded parent instead of
  guessing from trailing dash segments, which retired the parent directory's
  name whenever a workspace was named numerically.
- Reuse isAutoGeneratedCreatureBranchName so the -10 and -100 tiers retire.
- Drop the .codex/sessions root: Codex keeps the cwd inside the transcript
  rather than in a directory name, so the scan could only ever see a year
  folder. Reading transcript contents is not a trade this feature justifies,
  so the gap is documented instead.
- Honor CLAUDE_CONFIG_DIR, which relocates the bucket root.
- Delete the unused retirableLeafName export.

Tests write buckets with the real per-character encoding against a fake home,
covering POSIX, dot-directory, Windows drive and WSL UNC roots; all three
platform cases fail against the previous encoder.
2026-08-13 17:42:54 -07:00
Brennan Benson f336164715 refactor(ai-vault): extract the Claude project-dir encoder
The bucket-name encoder and its scope-boundary check were private to the
session scanner, so a second consumer had to reimplement them — and got the
per-character encoding wrong. Move both to a shared module with direct tests.
2026-08-13 17:42:42 -07:00
Neil b53c6d41df fix(terminal): clear CDPATH when resolving the wrapper directory (#14387)
* fix(terminal): clear CDPATH when resolving the wrapper directory

cd consults CDPATH for a relative operand and echoes where it landed, which the command substitution captured — wrapper_dir came out wrong, the lookup resolved to the tombstone itself, and git died at 127 with a working git on PATH.

Also pins the self-reference guard that turns that failure into a clean 127 rather than repeated self-exec; deleting it previously left the suite green. Both new tests are mutation-verified: an earlier version of each passed with the bug reintroduced because the CDPATH fixture mirrored only the first path segment.

* test(terminal): pin the wrapper guards that survived mutation

A mutation campaign found several security properties with no coverage. Most important: nothing asserted the Windows *reject* path for relative candidates, so deleting it reopened the cwd hijack while every accept-path assertion stayed green.

Also pins the separator variable value (any other character silently un-pins the trailing-separator fix), and grounds the interpreter-search test in the running process cwd — a relative PATH entry resolves against that, so the previous fixture in a tmpdir passed whether or not the guard existed. Each is mutation-verified.
2026-08-13 17:38:33 -07:00
Jinwoo Hong 54aa22b2df fix(mobile): self-heal host opens and harden session liveness (#14333) 2026-08-13 17:15:03 -07:00
Neil 31fcf8e2f0 fix(terminal): name the POSIX interpreter directly when generating on Windows (#14386)
The resolver could never succeed on Windows — no absolute candidate exists there and the PATH search split on the POSIX delimiter — so it always fell back to the ambient lookup it exists to avoid, on a wrapper Git Bash and WSL panes do execute.

Also rejects interpreter candidates containing whitespace (a shebang cannot quote) or that are directories (X_OK alone is true for those), and replaces the sentinel trailing-separator strip, which corrupted paths containing the sentinel, with a comparison against a variable holding the separator.
2026-08-13 17:08:36 -07:00
Brennan Benson d16092e503 fix(updater): recover renderer shutdown checkpoint (#14373)
* fix(updater): recover renderer shutdown checkpoint

* test(updater): cover checkpoint recovery in Electron

* fix(updater): keep staging failures blocking
2026-08-13 17:06:36 -07:00
Brennan Benson 537864a248 Fix Codex hook trust before manual shell launches (#14326)
* fix codex hook trust before shell launch

* fix packaged cli preflight dependency

* fix codex shell preflight safety

* fix Codex shell preflight settings and startup safety
2026-08-13 17:02:28 -07:00
Neil 6a0c8fa541 fix(ssh): reapply the reattach-identity work and stop the fallback fence stranding moved panes (#14384)
* Reapply #13326 and #13928 (un-revert #14361)

Restores the SSH reattach-identity and daemon-occupancy fixes. Reverting them
reintroduced their P0s, filed as STA-4224, STA-4225, STA-4227, STA-4230,
STA-4232, STA-4233 and STA-4234 against #14361.

The tab loss that motivated the revert is fixed in the commits that follow, so
this reapplication is not a straight redo.

* fix(relay): stop the fallback attach fence refusing a pane that moved tabs

The primary fence was moved to the shell's own incarnation precisely because
paneKey/tabId froze the pane's LOCATION at spawn and refused panes that had
merely moved. The fallback that older clients fall into kept the old rule, so
the correction never reached it — the same 'the rule exists, but this path does
not ask it' leak this work has hit repeatedly.

A refusal here is not recoverable: an identity mismatch never grounds a respawn,
so the pane keeps a live shell it can no longer reach and renders blank.

Narrowed to paneKey, which is the identity; the tab is a location. Restoring the
tabId comparison reddens the new test.
2026-08-13 16:45:24 -07:00
Neil 622de8009c fix(terminal): bake the wrapper interpreter and emit CRLF for cmd (#14382)
* fix(terminal): bake the wrapper interpreter and emit CRLF for cmd

Round-3 review: the shebang resolved bash through the inherited PATH before any script hygiene, so a relative or empty PATH element let an untrusted checkout supply the interpreter. Bakes an absolute verified interpreter with an env fallback.

Normalizes trailing separators in the cmd comparisons; without it a wrapper-dir entry spelled with a trailing backslash escaped self-exclusion and the wrapper tail-chained to itself forever.

Found while verifying on Windows: cmd resolves call targets by byte offset and fails on LF-only files once they grow (worked at 2.4 KB, failed at 3.7 KB), so the Windows wrappers now emit CRLF. Also avoids two cmd parser traps: a trailing backslash before a closing quote in an if comparison, and percent expansion inside rem comments.

* fix(terminal): resolve the wrapper interpreter from absolute PATH entries too (STA-4226)

The well-known-candidate list would fall back to an ambient env lookup on distributions that place bash elsewhere (NixOS, Guix), restoring the exposure. Search absolute PATH entries before giving up, skipping relative and empty ones because those mean the current directory.
2026-08-13 16:39:13 -07:00
Brennan Benson 45736ca8dd feat(dashboard): give the agent map a real filter panel (#14338)
* feat(dashboard): filter the agent map by multiple hosts

The map's host control was a single-choice segmented row in its own header:
pick All, Local, SSH, WSL, or Remote — never two at once. It also sat outside
the shared filter menu, so the menu's badge never counted it and "Clear all
filters" could not reach it.

Hosts are now checkboxes in the filter menu alongside agent state, so a fleet
split across a Mac and a Windows runtime can show both and hide the rest.
Hosts that contribute no agents are omitted, and the section hides entirely
for a single-host fleet.

Lifting `hostFilter` out of AgentMap also drops `compact`, which existed only
to hide the segmented control next to an open terminal panel.

The filter-option builders move to agent-dashboard-filter-options.ts to keep
AgentDashboardToolbar under the 400-line cap; that move is mechanical.

* feat(dashboard): give the agent map a real filter panel

The map's filters were a single-choice host row in its own header plus a few
rows borrowed from the board's dropdown. This replaces them with one panel that
owns every map facet.

- Quick views: Everything / Needs me / Stuck / Unread / Last 30 min /
  Long runners / Stale > 3d / Orchestration. Each replaces the filters
  wholesale rather than stacking on whatever was set.
- Provider (agent type) checkboxes.
- Three time ranges — session lifespan, since last message, time in current
  state — on a non-linear scale so minutes and days are both reachable.
- Orchestration flows select the coordinator *and* the agents it dispatched;
  a children-only filter hid the half that explains the flow.
- Sections collapse, each showing its value when closed. A section that is
  filtering forces itself open so a collapsed row can never be the reason the
  map looks emptier than the filters claim.
- The shown-count moves into the panel header, next to the controls that
  change it, and now reflects the map's own facets rather than the board's.

The map's surface becomes a Popover, not a DropdownMenu: the panel holds range
sliders and a Radix menu swallows the arrow keys those need. The board keeps
its dropdown untouched via a new `filterControl` slot on the shared toolbar.

`ui/slider` renders one thumb per value so a two-value range works; the two
existing single-value callers are unchanged.

* fix(dashboard): preserve agent map filter semantics

* refine agent map filter facets

* perf(dashboard): keep map filters out of main view
2026-08-13 16:31:32 -07:00
OrcaWinandBrennan Benson e1ee1b3ef3 fix(macos): recover severed terminal TCC attribution after updates (#13992)
* fix(macos): recover severed terminal TCC attribution after updates

When a packaged update leaves the daemon healthy but TCC-severed (spawning
binary gone), surface a Manage Sessions toast and replace the daemon before a
new terminal only when zero live sessions remain. Does not broaden FDA or
auto-kill sessions. Addresses the Orca-specific path of #13594.

* fix(macos): coalesce severed-TCC toast checks and sync i18n keys

Add catalog entries for the Manage Sessions toast strings and guard overlapping
mount/focus probes with an in-flight latch so only one infinite toast can fire.

* test(macos): harden severed TCC recovery coverage

* fix(macos): clear recovered TCC warning

* fix(macos): clarify severed TCC warning copy

* fix(macos): bound TCC attribution health checks

* fix(macos): preserve bounded attribution checks after merge

* test(macos): use real execFile callback contract

* fix(macos): cover legacy daemon attribution

* fix(macos): clarify affected Orca terminals

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-08-13 16:30:44 -07:00
Jinwoo Hong 77b37d85e2 feat(vm): create workspaces from provisioned SSH roots (#14359)
* feat(vm): use recipe-provisioned SSH roots

* fix(vm): preserve ordinary create failure timing

* test(vm): prepare provisioned root SSH fixture

* ci(vm): enable SSH setup for provisioned root E2E
2026-08-13 16:23:22 -07:00
Jinwoo Hong f9f55075f6 fix(vm): adopt provisioned SSH checkout roots (#14353) 2026-08-13 16:23:22 -07:00
Jinwoo Hong 99d19d4635 feat(vm): add provisioned root recipe contract (#14352) 2026-08-13 16:23:21 -07:00
Jinwoo Hong a5a998ea77 fix(vm): make hidden SSH cleanup retryable (#14351) 2026-08-13 16:23:21 -07:00
Brennan Benson c3b8c145e2 fix(agent-status): preserve Codex escape interruption (#14372) 2026-08-13 16:19:26 -07:00
Neil a27d046e5e fix(terminal): reject cwd-resolving PATH entries and harden the legacy wrappers (#14370)
* fix(terminal): guard cmd wrapper PATH walks against an empty variable

An empty PATH or cleaned PATH left the substitution with an unbalanced quote, desynchronizing cmd parsing so the not-found branch emitted a parse error instead of its message. Reproduced and fixed on real Windows.

* fix(terminal): reject relative PATH entries and drop the dirname dependency

Adversarial review found the STA-4169 fix incomplete: it dropped empty PATH elements but kept relative ones, which resolve against the current directory identically. A repo-local git was still executed via PATH=. or node_modules/.bin, reproduced in all three wrappers.

Also removes the external dirname call in the POSIX wrapper (an unresolvable dirname silently made wrapper_dir the cwd, so the wrapper failed to exclude itself and reported git missing while git was on PATH), compares against the cached wrapper dir in the cmd subroutine where %~dp0 is rebound by CALL, and probes .cmd after .exe so a non-.exe git is still found.

* fix(terminal): test rooted paths in pure batch, not via an external tool

The rooted-path guard shelled out to findstr, which cmd resolves from the current directory first — so a repository-local findstr could run, and a malicious one could report success for every entry and defeat the guard entirely. Same hijack the guard exists to prevent. Now a pure-batch substring test with no external process.

Splits the Windows wrapper templates into their own module to stay under max-lines without a suppression.

* fix(terminal): reject drive-relative PATH entries and fix no-slash wrapper dir

Round-2 review: PowerShell IsPathRooted accepts drive-relative C:foo, which still resolves against the current directory on that drive. IsPathFullyQualified is absent on Windows PowerShell 5.1 (verified 5.1 on the test host), so match the same prefixes the cmd wrapper accepts.

The POSIX %/* strip yields the file name when the path has no slash, so wrapper_dir became the name, self-exclusion missed the shim dir, and the lookup resolved back to the wrapper (spurious 127). Handled with a case split.

Also probes .bat, since the replaced lookup honored PATHEXT.
2026-08-13 16:07:49 -07:00
Brennan Benson 3b082a9470 fix(worktree): enforce retired names at creation 2026-08-13 15:56:03 -07:00