Commit Graph
3429 Commits
Author SHA1 Message Date
Jinwoo-H 531b620a44 fix(browser-preview): deny runtime permissions in document previews 2026-08-27 18:52:21 -04:00
Jinwoo Hong 12fb8e75dc feat(browser-preview): render remote HTML docs locally over an orca-preview scheme (STA-5557) (#16679) 2026-08-27 17:42:39 -04:00
Jinjing cda2280d63 Show all automations (#16532)
* Add all-host automations with scoped ownership and multi-authority suppo

Enable automations to run on multiple hosts (SSH targets and local) with
owner-fenced mutations, scoped list queries per host, and conflict
resolution. Introduces desktop and runtime authorities as distinct
automation storage owners, with per-host caching, invalidation, and
retry scheduling on the renderer. Captures registration generations for
SSH hosts to survive re-adoption. Adds CLI support for destination
selection and conflict recovery.

* Filter automation create projects by destination host

Only offer projects available on the selected destination, preventing
the mismatches that would fail at submit time. Auto-adjust the project
selection if it becomes unavailable when the destination changes.

* Add runtime storage authority support for automations

- Support both runtime and desktop as automation storage authorities
- Make owner preconditions optional for legacy-client compatibility
- Cache automation list projections to improve performance
- Add per-row repo/worktree resolution for cross-authority collisions
- Extend automation.list RPC to always include owner metadata

* Replace child_process.execFile with runProcess for external automations

- Migrate external-manager to use cross-platform runProcess wrapper per child-process safety policy
- Abstract electron app/ipcMain APIs in orca-runtime via environment accessors
- Install fake app environment in automation tests for consistent setup
- Reorganize imports to use specific module paths (ssh-target-registry, agent-detection, browser-error)
- Remove external-manager from child-process import allowlists (no longer violates direct import)

* Unify desktop automation CRUD onto the local runtime RPC surface

The desktop authority now speaks the same automation.* RPC contract as
remote runtimes, via callRuntimeRpc({kind:'local'}) -> runtime:call ->
the shared RpcDispatcher. The automations:list/listRuns/create/update/
delete/runNow IPC arms, their preload members, and every renderer
desktop-vs-runtime transport fork are retired; the runtime methods are
the single implementation of scoped lists, owner fencing, and change
publication for both transports (mobile clients already exercised them).

The desktop probe scheduler's priority lease survives the move as an
AutomationService hook the IPC registration installs and the runtime
methods take, so Orca's own automation traffic still parks queued
external-manager probes.

External-manager scope arms and dispatch-loop plumbing stay on IPC by
design; automation change events keep their existing channels (renderer
ingestion already converges them by authority).

* Remove automation ghost SSH tombstone scanning

This functionality for synthesizing tombstones for automation-referenced SSH
targets is no longer needed as part of the automation system refactoring.

* Refuse orphan automations at dispatch time, not migration time

Remove migration-time disabling of orphan automations and the `enabledDecidedBy` field. Dispatch now refuses orphans at runtime instead, simplifying state management and UI. Orphans are left unstamped and enabled; dispatch refuses to run them via `resolveAutomationRunTarget`.

* Show all automations in flat table with unified filter menu

- Replace host picker component with comprehensive Filters menu supporting status, last run, agent, and host filters
- Flatten automation list layout to single table instead of host-grouped sections
- Add Host column to display execution host for each automation
- Display active filters as removable pills below toolbar
- Delete unused AutomationHostPicker* components

* Add automation owner fencing and destination validation

- New AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY for owner preconditions; legacy clients get owner metadata snapshotted at RPC boundary for compatibility
- Editor captures and revalidates automation destination before save, preventing silent retargeting if SSH infrastructure changes mid-edit
- SSH target types now isolate renderer-authored fields; generation is server-owned and stripped by IPC handlers

* Route automation recovery actions to the origin host

When an automation action fails due to owner fencing, recovery verbs
("Update server", "Reconnect") must run on the host where the refusal
originated: the row's captured owner for row operations, or the
destination the create dialog captured, not the list's filtered host.

* Remove external manager scope limitation notices

Consolidate create destination eligibility checks with a unified predicate
and fix the bug where desktop repo IDs could be sent to runtime hosts where
they cannot resolve.

* Persist only store-derived automation contexts, not client-perspective o

Store contexts must never be based on client-provided runContext or sourceContext
values—clients speak a different perspective (e.g., 'runtime:<id>' for host IDs
they assign), and persisting those makes the store projection orphan automations
it actually owns. Derived contexts now take precedence in create and update paths,
with explicit null still honored to clear a value. Tests verify this by simulating
drift after storage and confirming that moves re-derive while toggles preserve.
2026-08-26 09:50:12 -07:00
Neil 44a3ba59e6 test(git): isolate worktree-shared-directories git config portably (#16582)
`os.devNull` is `\\.\nul` on win32. Git normalizes it to `//./nul` and rejects
it as a config path, so every `git` call in this suite threw in `beforeEach`
and 15 of 19 tests failed on Windows. POSIX resolves the same constant to
/dev/null, which Git accepts, so CI never saw it.

Point GIT_CONFIG_GLOBAL at a real empty file in a private mkdtemp directory,
matching how skill-git-tree-identity and skill-windows-workspace already
isolate, and use GIT_CONFIG_NOSYSTEM instead of GIT_CONFIG_SYSTEM.

Set both on `process.env` rather than only on the suite's `git()` helper.
`resolveWorktreeSharedDirectories` runs its own `git check-ignore` through the
production runner, and `GitRuntimeOptions` carries no env, so the runner
inherits `process.env`. The per-call override never reached the code under
test: a host `core.excludesFile` could make a fixture that is not gitignored
come back as ignored.

Fixes #15409
2026-08-26 04:36:28 -07:00
Neil 19e9ec695b perf(windows): ship the native process table to Windows relay hosts (#16598)
* feat(windows): let a relay host bind the native process table directly

The CIM fallback from #16550 answers on relay hosts, but it costs a
powershell.exe and ~1.4s per scan where the native reader costs ~57ms.
It is a parachute, not the destination.

Teach the loader a second source: the desktop app keeps resolving the
npm package, and a relay host -- which has none of our node_modules --
binds a bare `windows-process-tree.node` staged beside the bundle. The
CIM scan stays as the last resort, so a host with neither is unchanged.

Bind the addon directly rather than its package wrapper. lib/index.js
adds only a queue over getProcessList, and that queue is the wedge this
module already defends against: it latches a module-global
requestInProgress with no try/catch. We hold our own single-flight and
deadline, so going straight to the addon drops the duplicate.

Measured on a Windows 11 SSH host with ~1490 processes, running the
relay-externals bundle from the deployed relay directory:

  no addon staged   nativeAvailable=false  1247ms  (CIM)
  addon staged      nativeAvailable=true     57ms  memory restored

Degradation was exercised on that host, not just in fakes: a truncated
upload, a text file, and a foreign-arch ELF each fall through to the
scan rather than throwing, and restoring a good addon recovers. A file
that loads but lacks getProcessList is rejected by shape, because
binding to it would reject every read forever where falling through
still answers.

No artifact is staged yet, so this is inert until the packaging change
lands: today every relay takes the same CIM path it does now.

* build(relay): ship the Windows process-table addon to relay hosts

The CIM scan restored correctness on Windows SSH hosts, but it costs a
powershell.exe and ~1.4s per read where the native addon costs ~57ms. It
was always the floor, not the destination.

The addon cannot be npm-installed on a relay host: it carries a
binding.gyp, so npm rebuilds from source and the build wants
Spectre-mitigated libraries even where MSVC is already present. The
binary inside the published tarball loads, but predates our patch and
still caps enumeration at 1024 processes -- on a 1486-process host it
returned exactly 1024 rows with the querying process among the missing,
which reads as unavailable only under load. No published alternative
clears the bar either; the one fork with a working prebuild story still
carries the same cap.

So build it where a compiler exists and ship the result. The build script
refuses unpatched source -- checking the source rather than trusting the
install, because the Spectre hunk fails loudly while the 1024 hunk fails
silently -- and verifies the PE machine field so a cross-build cannot
emit host arch for another target.

The artifact is optional: hashed when present so a relay carrying it
never shares an immutable directory with one that does not, and never
probed, since requiring a file only a Windows build machine can produce
would make a correct relay read as MISSING and redeploy forever. Builds
on any other OS keep using the scan, unchanged.

arm64 cross-compiles from the x64 runner but needs the optional MSVC
ARM64 toolset, so it stays best-effort: a runner image without that
component should cost arm64 relays the fast path, not fail the release
the x64 relay is riding on. ORCA_REQUIRE_RELAY_NATIVE_ADDONS is a
per-arch list rather than a flag for exactly that reason.

* build(relay): require the arm64 process-table addon too

The arm64 cross-compile is no longer unproven. On a Windows x64 machine
with the MSVC v143 ARM64 build tools component installed, node-gyp
--arch=arm64 produces a genuine ARM64 image:

  x64    machine=0x8664  152064 bytes
  arm64  machine=0xaa64  139776 bytes

So arm64 stops being best-effort and joins x64 in the required list. It
was only best-effort because the component is optional and I had not seen
it succeed; a runner image without it now fails the build with MSB8020
naming the missing component, and that step runs before the long
packaging step so the failure costs seconds rather than twenty minutes.

The env var stays a per-arch list rather than reverting to a flag, so a
future arch can land best-effort before being promoted the same way.
2026-08-26 03:14:45 -07:00
Neil 7f034a182f docs(windows): correct why the process-tree addon is not installed on relay hosts (#16565)
The note said the package "ships no prebuilds". It does: the published 0.8.0
tarball carries build/Release/windows_process_tree.node, apparently an
accidentally published MSVC build directory (.obj and .tlog files ship with it).
The conclusion was right and the reason was wrong, so record what was actually
measured on a Windows SSH host with 1486 processes.

Installing it normally rebuilds from source, because the tarball carries a
binding.gyp and npm runs node-gyp regardless of what is already compiled inside.
That build fails with MSB8040 (Spectre-mitigated libraries) even on a host that
already has MSVC Build Tools 2022 -- the requirement our binding.gyp patch
deletes, and patches do not cross SSH.

Skipping the build keeps the tarball binary, which loads (it is N-API) but
predates the src/process.cc patch and still caps enumeration at 1024. On that
host it returned exactly 1024 rows with the querying process among the missing,
which the self-presence guard rejects -- so it would work on a quiet machine and
fail only under load, the shape of bug that survives testing.

Also records the measured cost of the fallback, since the table's 706ms figure
is from a 1050-process host and reads as more headroom than there is, and names
the fix for the tracked gap: ship our own patched .node as a relay asset, as
config/relay-assets already does for node-pty.
2026-08-26 02:59:08 -07:00
Neil 1fafccb26b fix(settings): use Workspace Directory for the Create-project default path (#14767) (#16583)
* fix(settings): use Workspace Directory for the Create-project default path

`repos:getDefaultCreateProjectParent` hardcoded `join(homedir(), 'orca',
'projects')` and never consulted the settings store, so Settings -> General ->
Workspace Directory had no effect on the Location field of "Create new project".
Users had to retype the path every time, or fake it with an NTFS junction.

Resolve the parent from the store instead, through the same rule the rest of the
app uses for a host preference: `host override ?? client default`, i.e.
`getEffectiveHostSetting(settings, LOCAL_EXECUTION_HOST_ID,
'defaultWorktreeLocation', settings.workspaceDir)`. This handler only ever
answers for the local host, and a local-host override previously could not win
either.

A seeded value is not a user choice. `workspaceDir` is never blank -- new
installs seed it with `~/orca/workspaces` -- so treating any non-blank value as
configured would silently relocate every existing user's new projects into the
worktree root. Worktrees nest at `<workspaceDir>/<repoName>/<branch>`, so such a
project would then host its own worktrees inside its own working tree. Compare
against `getDefaultWorkspaceDir(homedir())` (now exported) via
`normalizeRuntimePathForComparison`, and keep `~/orca/projects` for blank,
whitespace-only, and untouched-default values.

Also scope the `~/orca/projects` shorthand in `formatCreateProjectParentSummary`
to the fallback path itself. Otherwise a user with Workspace Directory set to
`J:\PROJECTS` saw the summary line claim `~/orca/projects` while the field held
`J:\PROJECTS`.

Fixes #14767

* fix(settings): keep configured orca/projects paths verbatim in the create summary

The collapsed Location summary used a tail match on orca/projects, so a
configured directory like /data/orca/projects rendered as ~/orca/projects.
Scope the shorthand to usual home layouts and pin the lookalike cases.
2026-08-26 02:36:38 -07:00
Neil 5e900b10b3 fix(windows): let a build with no job exports still retire a dead agent (#16563)
* fix(windows): let a build with no job exports still retire a dead agent

#16419 (a1ec0479e2) routed the foreground poll's liveness question to the job
object. On a build whose node-pty lacks the job exports the read returns null,
which judgeCachedAgentJobEvidence reports as 'unavailable' -- correctly refusing
to treat loss of contact as death. But that is the wrong reading here, and it
matters more than it looks:

**every shipped Windows release is such a build.** The patch adding
listJobProcessIds is in no v1.4.* tag, 1.4.188 included (#16059). So for every
Windows user today the read is null on every poll, the verdict is always
'unavailable', and the retire path never fires at all. Their panes keep a dead
agent's name indefinitely, and because a non-null cache makes idleNoEvidenceShell
false, the refresh also stays pinned at the 1s TTL instead of backing off to 15s.

That is worse than what #16419 replaced: the forked probe was expensive but it
did retire.

The distinction the verdict was missing is between "we could have asked and could
not" and "there is nothing to ask". Only the first is unverifiable. A build with
no job exports is the second, so it now returns 'unsupported' and the
authoritative scan decides alone -- exactly as it already does off Windows.

Deliberately NOT falling back to the forked console probe: that is the #10857
storm this whole path exists to avoid, and re-forking per poll for the entire
current fleet would be the worse trade. The scan that reaches this branch has
already reported available and found no agent; trusting it needs no fork.

Also deliberately not age-based: an earlier draft retired on age alone, which
would expire a LIVE agent that a scan could have confirmed, since 'unproven'
short-circuits the scan.

isWindowsPtyJobReadable() sits beside the read rather than being imported from
windows-pty-job, so one module mock controls both facts. Without that, every
Windows-simulating test on a macOS runner silently took the unsupported path,
because isPtyJobOwnershipAvailable() is false off Windows -- the suite would have
been testing a configuration no Windows user has.

* test: give every job-membership mock the readability export

Nine of the nineteen files mocking windows-pty-job-membership supplied only
readWindowsPtyJobProcessIds, so isWindowsPtyJobReadable resolved to undefined in
those suites. They pass today only because none of them reaches the call; the
first one that does gets 'isWindowsPtyJobReadable is not a function'.

Left alone this is the same trap the export exists to prevent -- a suite that is
green about a configuration no user runs -- just arriving as a crash instead of a
wrong answer. All nineteen now declare which build they simulate.
2026-08-26 00:10:32 -07:00
Jinjing 4d2dc0fae5 test: pin cross-version browser placement test to explicit baseline (#16554)
* test: use explicit baseline for cross-version browser placement test

Pin to v1.4.184 to ensure consistent testing against the release
predating client placement. This avoids coupling the legacy-baseline
bump to unrelated schema refactors in newer versions.

* fix(windows): treat inaccessible processes as alive in tests

When checking process state on Windows, EPERM (permission denied) indicates
an inaccessible but live process. Only ESRCH (process not found) proves
exit. Correct isAlive() to distinguish these cases.

Also add windowsHide:true to child process spawns and use explicit SIGKILL
when force-killing the host process.
2026-08-26 00:02:12 -07:00
Neil 1c9fb84b77 fix(worktree): stop push-target rollback deleting a sibling's remote (#16569)
A push target that reuses an existing Orca-created fork remote inherits
ownership of it (`remoteCreated = isRemoteCreatedByKnownWorktree(...)`),
so the final worktree to be deleted can remove it. Rollback then reused
that same flag to decide whether to undo its own work -- but a reused
remote was not added by this call, and a live sibling worktree is still
pushing to it. A failed fetch during create therefore deleted a remote
another worktree depends on.

Track `remoteAddedHere` separately: ownership stays inherited for
cleanup, while rollback only removes a remote this call actually added.
Both the local and the SSH path had the same bug and are fixed together.

Original work by Jinjing (AmethystLiang) in 616d2a4ec8c; split out of
that branch so the release fix in #16550 stayed a clean cherry-pick.
2026-08-25 23:48:54 -07:00
Jinwoo Hong 3c5c908451 fix(automations): scope project refs to destination host (#16552) 2026-08-25 23:32:21 -07:00
Jinwoo Hong 868fc39d32 fix(worktrees): refresh paired clients after external discovery (#16557) 2026-08-25 23:18:30 -07:00
JinjingandNeil e4d95e032d fix(windows): restore a CIM fallback for relay hosts with no native binding (#16550)
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
2026-08-25 23:15:37 -07:00
Jinjing 933345d347 Clarify upstream divergence stats for rebased branches (#16358)
* Clarify upstream divergence stats for rebased branches

When a branch is rebased, it still tracks the pre-rebase upstream
while comparing against the new base. Move upstream arrows to the
head line to prevent them being confused with compare-base counts.

* Show upstream divergence stats independent of compare base

Measure HEAD against upstream regardless of compare-base state,
so divergence indicators stay visible even when comparison is
missing, loading, or failed. Also use cross-platform temp paths
in tests.

* Show commit counts against compare base, not upstream

Upstream divergence (↑/↓ against tracking branch) was confusing for
rebased branches — the counts appeared beside the base ref but measured
against the upstream branch. Show only the compare base count instead,
on the line that names it.

* Report branch divergence in both directions

Rebased branches are typically ahead AND behind their base; a single count
hides this case. Use symmetric range with --left-right --count to capture
both directions efficiently, then expose commitsBehind in the UI alongside
commitsAhead.

* Use semantic names for i18n keys and template variables

Rename hash-based translation keys to descriptive identifiers and replace generic value0/value1 placeholders with semantic variable names like `count` and `ref`. Improves code maintainability and makes translation strings self-documenting.
2026-08-25 22:19:04 -07:00
Jinjing 5479bd9159 refactor(task-page): split task page into focused modules (#15163)
* rm unused files

* rm unused files

* fix(task-page): clean readiness lint findings

* Add GitLab IPC timeout wrapper and improve error handling

- Extract GitLab timeout logic into reusable `withGitLabIpcTimeout` wrapper to protect all GitLab API calls from hanging indefinitely
- Apply timeout protection to all GitLab list and fetch operations
- Add error handling for GitHub and Linear issue creation operations
- Fix event bubbling in GitHub work item row to prevent nested button clicks from opening detail page
- Remove unused `usePRReviewCellState` hook
- Consolidate redundant imports

* refactor(task-page): extract components and improve provider handling

- Add glab timeout handling (30s) to prevent IPC thread blocking
- Extract GitHub assignee/review components to dedicated files
- Improve GitLab work item row keying (repoId:id) and keyboard event handling
- Add context-aware error handling for Jira creation failures
- Refactor GitHubAssigneeAvatar to use shared GitHubUserAvatar component

* Add timeout support and error handling for GitLab operations

- Admission control times out queued work after 30s to prevent
  indefinite queueing behind saturated operations
- Mutation errors now display to users via toast instead of failing
  silently

* Consolidate workspace attachment labeling into unified utility

Extract common label-generation logic from GitHub and Linear
work-item components into a single getWorktreeAttachmentLabel
function, removing duplication across attachment types.

* Improve TaskPage accessibility, i18n coverage, and error handling

- Add missing aria-labels, roles, and semantic attributes for improved screen reader support
- Extract hardcoded UI strings into i18n system with translate() calls
- Add error handling and proper abort signal support for async operations
- Use locale-aware date formatting throughout
- Fix pagination disabled state and reviewer suggestion merging logic
- Improve async state management with proper refs and effects
- Add Textarea component import for Jira dialog

* Improve TaskPage accessibility and i18n key naming

- Add DialogTitle/Description with i18n to Linear issue dialog
- Use useId to improve aria-labelledby in GitHub selectors
- Replace hash-based i18n keys with semantic names
- Use Object.hasOwn instead of `in` for safer filter checks
- Fix PR review cell to clear input only on success

* Add missing dependencies to TaskPage hooks and useCallback/useEffect arr

Fixes exhaustive-deps warnings by adding missing setters, refs, and computed
values to dependency arrays. Refactors GitHub and Linear issue state handling
to compute values from pageData where available, with fallback to local state.
Moves imperative ref updates into useEffect to properly track dependencies.

* Fix TaskPage ref timing and null repo selection state

Treat null newIssueRepoId as a valid selection, and use useLayoutEffect to synchronize the provider context ref before paint rather than after.

* Extract Linear issue dialog components and fix popover scroll styling

- Consolidate scroll styling: apply popover-scroll-content and scrollbar-sleek classes to PopoverContent wrappers
- Remove redundant max-h-60 overflow-y-auto styles from inner picker divs
- Fix GitHub new issue repo selection to explicitly target first selected repo on fresh mount
- Correct CacheEntry import paths from store/slices/github to store/github/cache-model
- Update tests to reference extracted dialog components instead of TaskPage.tsx

* Improve GitHub task page i18n and fix issue creation edge cases

- Add i18n support to GitHub work item aria-labels (draft PR, PR, issue)
- Optimize work item row by extracting repeated source context call
- Add safety check to prevent opening detail page when issue URL is missing
- Fix dependency reference in detail opener hook
- Extend GitLab job trace timeouts (60s backend, 65s frontend) for slow logs

* Increase GitLab job trace fetch timeouts

Job traces can outlive the runner's 30-second default timeout.
Extend fetch operations to allow 60–65 seconds to complete.

* Verify sourceContext variable extraction in github row test

Update expectations to check that sourceContext is assigned to a
variable rather than called inline, matching the refactored component
implementation.
2026-08-25 21:28:21 -07:00
Neil a1ec0479e2 fix(windows): revalidate PTY liveness from the job object, not a forked helper (#16419)
* fix(windows): answer console membership from the job object, not a forked helper

node-pty answers "which processes are attached to this pane's console?" by
FORKING a helper, because GetConsoleProcessList must run from a process
attached to that console. Orca asked on a foreground poll, per pane, so each
read spawned a conpty_console_list_agent -- hundreds of hidden processes
exhausting RAM within minutes, respawning as fast as they were killed (#10857).

QueryInformationJobObject has no console-attachment constraint: any process
holding the job handle can ask. Orca already creates that job per PTY, and
listPtyJobProcessIds has exposed it since the W1/W2 work with zero callers.
One syscall, no children.

Semantics the three call sites rely on are preserved: a root-only set still
proves the shell is alone (so a stale agent can be retired), and size > 1 still
proves something is running under it. The single difference is that a
descendant detached from the console stays in the job -- which widens the set,
the conservative direction for every caller.

Also fixes the third call site, which returned { available: false } whenever
membership was unavailable AND a recognized agent existed -- i.e. exactly while
an agent was running. Membership only ever narrowed the candidate list, so an
unavailable answer now leaves it unfiltered instead of failing the whole
resolution.

The no-fork test is asserted through a module-level vi.mock of
node:child_process. A vi.spyOn of a require()'d child_process does not
intercept the module's own import binding: the first version of that test
passed with a fork() deliberately reintroduced.

* fix(windows): keep console attachment for the candidate filter

Readiness review caught that this PR changed two different questions as if they
were one, and the repo's own plan doc had already said so:

  "The job is the wrong set here -- it would re-admit precisely the detached
   process the filter exists to drop."  (windows-wsl-root-cause-plan.html, Use B)

The two uses:

- Use A, `size > 1` at local-pty-provider and the daemon tracker -- "is anything
  in this pane besides the shell?". The job answers this, in-process and with no
  fork. Unchanged from the previous commit.
- Use B, the candidate filter -- "which of these are ATTACHED TO THIS CONSOLE?".
  Its whole job is dropping a descendant that detached, and the job object keeps
  those, so answering it from the job makes the filter a no-op in its motivating
  case: a detached `Start-Process droid` would be granted byte authority, and a
  detached sibling would make an attached agent look ambiguous.

Use B goes back to GetConsoleProcessList, in its own module named for what it
answers, with its fail-closed null restored. That path is not the #10857 storm:
it runs only when a recognized agent candidate already exists, not on every
foreground poll. Bounding it to one pooled supervised helper is the remaining
half, and per the plan doc either half alone takes #10857 from unbounded to one.

My earlier claim that widening membership is "the conservative direction for
every caller" was wrong -- true for Use A, backwards for Use B. The hardware run
did not catch it because I measured a WSL pane, where the superset is harmless,
and never a detached GUI child, which is the divergence.

* fix: restore the coverage and ratchets the module split dropped

Round 2 of review. Two blockers, both from moving the forking code to a new
file without moving what guarded it.

- The child_process import ratchet was RED: windows-console-attached-processes.ts
  imports node:child_process and was unlisted, and the old entry was stale. I
  never ran that suite -- lint and the providers/daemon tests both pass without
  it, which is exactly the gap the ratchet exists to close. Entry repointed;
  count unchanged at 159.
- The forking module had ZERO tests. Its 11 assertions -- bounded timeout,
  single kill, spawn error, malformed message, helper-pid removal -- were in the
  file that now answers a different question, so the module that actually caused
  #10857 was shipping untested. Moved with the code.

Also: nothing pinned the round-1 fix itself. No test drove console attachment to
null and asserted the fail-closed result, so re-deleting that branch would have
gone green. Now covered, and verified to fail when the branch is removed.

Cleanups the split left behind: `consoleMembershipUnavailable`/`consoleProcessIds`
renamed to `pane*` where they now hold job membership, the duplicated
`WindowsConptyMembershipDeps` type name, comments still describing the console
on the job path, and eight reliability-gate paths pointing at the moved tests.

* fix(windows): let a superset job answer expire instead of vetoing retirement

Round 3. The job read had reintroduced #9258's bug by a new mechanism.

`size > 1` returned unconditionally, so any pane holding a console-detached
descendant never retired its cached agent. A WSL pane always holds some: the
measurement in this PR's own test recorded job [40980,104068,4888,69908] against
console [69908,40980], i.e. console said "shell alone, retire" while the job said
"three others alive, keep". #9258's third commit describes the identical failure
from the other direction -- a bare shell reading as [helper, shell] "looked like
it still had a child ... the foreground refresh held the exited agent's identity
indefinitely" -- and that is what came back.

It bites because the read branch that serves the cached name across a Windows
shell fallback is deliberately untimed: #9258 made it so on the stated assumption
that "the background refresh authoritatively retires it". Removing the retire
authority left the identity with no bound at all. Second-order: a non-null cache
makes idleNoEvidenceShell false, which pins the refresh at the 1s TTL, so an idle
WSL pane also scanned the process table every second forever.

A TTL on the read would have been the wrong fix -- untimed is deliberate, because
on Windows the fallback name is structurally uninformative. Instead the job answer
is treated as what it is: a SUPERSET of the console, which cannot tell a working
agent from a leftover. Proof of absence retires immediately (size 1, unchanged);
an inconclusive answer ages out at 30s; unverifiable (null) still holds forever
per ssh-execution-boundary.md. Only successful scans that found no agent advance
the clock -- a degraded scan returns before this -- so the fix cannot expire an
agent it simply failed to see.

Also from review:
- Restore the root requirement the forked probe had. Without it a set of one
  non-root pid -- shell gone, descendant alive -- read as "shell alone, retire",
  inverting the truth.
- Rename to windows-pty-job-membership.ts / readWindowsPtyJobProcessIds. The old
  name still said ConPTY console while reading the job, and conflating those two
  sets is precisely the bug aee07c24aa reverted. Same for
  windows-console-foreground.ts, which guards a job read now.
- Gate the two files that had no coverage: the job read and the retire path.

* fix(windows): bound the provider's job short-circuit too

The previous commit fixed the daemon retire path and left the identical bug in
the local provider, which I found while asking the reviewer to check for it.

local-pty-provider.ts returned the cached agent early on `size > 1` and that
early return skips the scan at the bottom of getForegroundProcess -- the ONLY
code that can delete ptyLastRecognizedForeground. So on a WSL pane, whose job
always holds console-detached plumbing, the short-circuit was permanent and the
identity could never be cleared. Same failure, second location, and the daemon
fix did nothing for it because this path never calls retireStaleForegroundIdentity.

The cache was a bare Map<id, name> with no timestamp, so bounding it needs one.
Added ptyLastRecognizedForegroundAt, stamped only when the recognized name
actually changes, and paired with every existing delete including pane teardown
so the new map cannot outlive the old one.

The 30s threshold now lives in windows-cached-agent-revalidation.ts rather than
being duplicated: that module already answers "can we revalidate this cached
agent without a scan", and the max age is the other half of that question.

Also renamed two tests that still said "ConPTY console presence" while driving a
job read. Re-conflating those two sets by name is how this PR got its first two
review rounds wrong.

* fix(windows): stamp the provider cache on every confirmation, not on change

My own previous commit was wrong, and wrong in the direction #9258 exists to
prevent. Review caught it; the test in this commit reproduces it first.

I stamped ptyLastRecognizedForegroundAt only when the recognized name CHANGED.
That makes the value the time of first recognition, so the age measures how long
the agent has been running rather than how long since we last confirmed it. For
a live agent recognized as the same name every cycle the stamp never moved, the
age crossed 30s and stayed there, and the short-circuit died permanently.

Two consequences, the second serious:
- every getForegroundProcess call on a >30s-old agent pane ran the whole-table
  scan, defeating the exact optimization the branch exists for;
- with the short-circuit off, one available-but-agentless snapshot was enough to
  delete a LIVE agent's identity, because paneMembershipUnavailable is false in
  this state so the degraded-scan substitution does not engage. That is the false
  "agent done" this code's own comment warns about.

The daemon path was already right -- it re-stamps refreshedAt on every positive
recognition -- so the same constant meant two different things in the two files.
Now both mean "time since we last saw the agent", which turns the bound from
"disable the short-circuit after 30s" into "force one revalidating scan every
30s": ~16-31ms per pane per 30s via the native process table.

Test asserts the scan count stops incrementing after the revalidation, and fails
against the stamp-on-change form.

Also correct the shared docstring, which had dropped the invariant the whole
design rests on, and stop calling this a WSL bug: the trigger is a persistent
console-detached job member plus a fallback that reads as a shell. wsl.exe is
not in SHELL_NAMES, so a plain WSL pane does not even reach this code -- WSL is
just where it was measured.

* refactor(windows): shrink the job-membership path

Elegance pass. No behaviour change -- all three mutation checks still bind
(restoring the size>1 veto, stamping only on name change, dropping the root
requirement each turn their tests red).

- windows-pty-job-membership.ts 54 -> 31 lines. A deps object carrying one
  optional function became a defaulted parameter, the accumulate loop became a
  filter, and the docblock lost two thirds of its bulk.

  It also lost a claim that was simply false: it said a widened set "is the
  conservative direction for every caller: it keeps a live agent rather than
  retiring it early". For the retire caller, never retiring IS the failure --
  that is the bug this stack just fixed, still being described as a feature
  three commits later.

- One local `identityOlderThan(ms)` in the tracker replaces two hand-rolled
  `Date.now() - refreshedAt` comparisons, one of which I had added.

- The provider's two parallel maps collapse into one Map<id, {name, at}>.
  Parallel maps meant every delete site had to remember its sibling, in three
  places; the reviewer flagged the leak risk and I fixed it by pairing them,
  which leaves the hazard for the next person. One map removes the class.

Comments trimmed to the load-bearing sentence throughout, per AGENTS.md.

* fix(windows): preserve foreground cache age evidence

* fix(windows): anchor cached agent identity to the pid that proved it

The job short-circuit and retirement veto only knew 'something besides the
shell is alive', so a detached leftover pinned a dead agent's name for the
30s age bound, and 30s of incomplete-but-successful scans could retire a
live one. The scan already knows which row proved the name: carry that pid
through the resolution, and judge the cache against the job with it --
membership of a known pid in a complete, inescapable job list is proof of
life (restamp, never expire), and its absence is proof of exit (retire now,
leftovers notwithstanding). Unanchored identities keep the age-bound
superset behavior.

* fix(windows): anchor the reported process, and let a scan refute a recycled pid

Review findings on the pid anchor:

1. The anchor followed the LEAF that proved a collapsed name: 'omp' reported,
   pi's pid stored. Pi exiting or restarting under a live OMP then read as the
   wrapper's exit -- retiring the identity before a scan that (degraded) may
   miss OMP, a false 'agent done'. resolveOuterWrapperForegroundIdentity now
   carries the pid of the process the name belongs to.

2. A bare numeric pid can be recycled inside the pane's job, making membership
   falsely confirm a dead identity indefinitely. Command lines are immutable,
   so a scan row holding the anchor pid without recognizing as an agent proves
   a different process: the resolution reports it (anchorPidForeign) and both
   consumers retire immediately. A query-denied row (command falls back to the
   image name) stays inconclusive -- never grounds to drop a live agent.

* fix(windows): find a recycled anchor pid in the full table, not the ppid walk

A squatter that inherited the pane job from a leftover whose creator then
exited is orphaned out of the shell-rooted descendant projection, so the
foreign-anchor refutation never saw its row. Pluck the anchor pid's row from
the same whole-table snapshot instead; a job member holding the pid is in the
table even when no ppid chain reaches it.

* fix(windows): survive an agent restart, and refute a squatter by name

Two review findings on the exit verdicts:

1. 'exited' deleted the cache before the scan, so an agent restarting under a
   new pid plus a degraded scan at that instant reported the shell -- a false
   'agent done'. Only the shell standing alone is decisive now; an anchor
   leaving a job that still has members downgrades to unanchored, age-bounded
   evidence and lets the scan decide. The daemon tracker keeps immediate
   retirement: its verdict path only runs after an available scan already
   found no agent.

2. The foreign-anchor refutation treated any recognized row as 'ours'. A pid
   recycled by a DIFFERENT agent now compares against the cached name the
   anchor is supposed to prove.
2026-08-25 20:32:54 -07:00
Neil 5a8b4aff7b perf(git): only schedule the upstream-ref poll for the repo that has one (#16443)
* perf(git): only schedule the upstream-ref poll for the repo that has one

A single global binding means at most one worktree holds a selected upstream
ref at a time, so every other repo's 2s poll woke only to stat an empty set.
Rebinding is synchronous and in-process, so reacting to it detects a newly
selected ref exactly as fast as polling did — the wake-ups were pure waste.

Measured at 100 repos with no selected ref: 0.811 -> 0.320 CPU-ms/s idle.

* fix(git-watch): re-read ref selection after building the poller

A rebind can flip back while the poller is being constructed. The concurrent
unbind sees statusRefPolling still null and correctly does nothing, so without
re-reading selection the in-flight build adopts a poller for a repo that no
longer holds a ref — reinstating the idle wake-ups this change removes.

* fix(git-watch): fence concurrent ref-poller starts with a generation token

Startup and each rebind could be mid-build at once, and the startup path
adopted its poller unconditionally. A rebind that won the slot first was then
overwritten without being unsubscribed, stranding that poller's timer and
visibility listener for the process lifetime. The generation names which
attempt still owns the slot so every loser tears down what it built.

Adds a regression test driving the real binding path; it fails if the poller
is dropped without unsubscribing.

Reported by CodeRabbit on #16443.
2026-08-25 18:51:41 -07:00
Neil 91a500712c fix(crash-reporting): see the renderer memory the heap counters never report (#16449)
* fix(crash-reporting): see the renderer memory the heap counters never report

Windows renderer crash 36048e26 arrived with 618MB of private renderer memory
and a `renderer_memory` breadcrumb reporting a 150MB V8 heap. Both numbers were
right: xterm scrollback lives in `Uint32Array` backing stores and glyph atlases
live in GPU transfer buffers, and neither is counted by `usedHeapSize`,
`mallocedMemory`, or Blink's allocator.

That made the report unanalyzable. `renderer_memory_highwater` is the crumb
carrying the subsystem census that names what grew, and it is armed on
`usedHeapSize / heapSizeLimit`. At 150MB of a 4192MB limit that ratio is 3.6% —
nowhere near the 60% mark — so the census never reached a single one of these
reports.

Measured on Windows (6 worktrees x 4 terminal tabs, 8000 lines each, this app
at 4218d505): filling 24 mounted panes moved the renderer working set from
210MB to 656MB while `usedJSHeapSize` stayed at 43MB for the whole run.

Sample the renderer's own OS footprint through `process.getProcessMemoryInfo()`
(available in the sandboxed preload) and:

- report `privateMB`, `residentMB`, and `outsideHeapMB` — the footprint minus
  everything V8 and Blink admit to holding — on every `renderer_memory` crumb;
- arm the highwater census on private-footprint marks (600MB / 1000MB) as well
  as the heap ratio, so growth outside the JS heap now carries the pane and
  store census that names it.

The footprint read is async, so a sample annotates with the previous read and
refreshes in the background: one interval of staleness is irrelevant to a
footprint trend, and awaiting it would make every sample reentrant. A shell
without the bridge, or a runtime that withholds the read, keeps sampling
exactly as before.

Retained-breadcrumb keys now distinguish the two threshold ladders; keying only
on `thresholdPct` collapsed every footprint crumb onto one slot.

crash-diagnostics.ts split at the max-lines budget: memory sampling moves to
renderer-memory-sampling.ts and the shared payload shaping to
crash-breadcrumb-data.ts.

* fix(crash-reporting): retain all renderer memory marks
2026-08-25 18:38:42 -07:00
Neil 76f7e785fc style(github): trim repo identity cache comments (#16517) 2026-08-25 18:23:57 -07:00
Neil 67f2fc9e7c perf(github): stop re-spawning the repo-identity probe every 30 seconds (#16450) 2026-08-25 18:16:58 -07:00
Jinwoo Hong c8567eb16e fix(sidebar): preserve hidden rows in manual order (#16488) 2026-08-25 16:46:42 -07:00
Brennan Benson efa3b972c2 fix(native-chat): prevent duplicate mobile prompt echoes (#15656) 2026-08-25 15:47:40 -07:00
Jinwoo HongandJinwoo-H a9781a4118 STA-4150: client-hosted remote browser (consolidated) (#15448)
Co-authored-by: Jinwoo-H <jinwoo@stably.ai>
2026-08-25 15:36:51 -07:00
Brennan Benson 98bdd653ab fix(native-chat): stop the spinner on a not-yet-flushed transcript (#16493)
* fix(native-chat): stop the spinner on a not-yet-flushed transcript

A brand-new agent session can take minutes to write its first JSONL line,
and one that is never prompted never writes it at all. The host emitted no
stream frame until the file resolved, so every native-chat client sat on a
bare spinner with the composer enabled but the transcript blank -- forever,
in the never-prompted case.

The resolve poll now reports the transcript as pending after a short grace,
and both host handlers emit a `pending: true` snapshot. It is deliberately
not a plain empty snapshot: an empty window sold as a settled read would
capture over retained history and unblock consumers that require a
trustworthy transcript (the launch-draft adoption would re-offer a prompt
the agent may already have taken).

Clients render it as the "start a chat" empty state while keeping the read
unsettled -- `awaiting-transcript` on mobile, an `awaiting` read phase on
desktop, which also stops the seed loop expiring into an error card for a
session that is simply new. New optional field only, so older clients
ignore it and still stop spinning.

* fix(native-chat): negotiate pending transcript frames
2026-08-25 15:06:47 -07:00
Jinjing e361da7fb7 Deleting skill (#16357)
* Add skill deletion with cross-platform transaction safety

Implements end-to-end skill removal with placement enumeration, dependency guards, and transactional recovery. Covers native, WSL, and remote hosts; users can delete canonical directories and alias placements (symlinked directories or files) in a single atomic batch. Includes UI selection flow, preview, confirmation, and results band. Block reasons (bundled, plugin, unowned, stale) gate deletions that would fail or contradict user intent.

* Organize IPC handlers into module subdirectories

Move register-core-handlers and skill-delete-ipc-handlers into
dedicated subdirectories for improved code organization and to
reduce the flat structure in src/main/ipc/.

* Make skill deletion recovery transactions idempotent

Defer journal cleanup until both staging removal and receipt cleanup succeed, leaving the journal in place for startup to retry if either operation fails. This ensures the recovery process is safe to run multiple times without leaving partially-deleted skills.

* Consolidate skill-delete files into dedicated module

Reorganize skill deletion functionality into a modular structure under
`src/main/skills/skill-delete/` with simplified file names. Remove the
redundant `skill-delete-` prefix from file names since they now live in
the dedicated directory. Update all import paths throughout the codebase
to reflect the new structure, including imports from IPC handlers and
RPC methods.

* Fix broken import paths and add deletion robustness improvements

Import paths using `..//'` were invalid and broken. Replace with explicit
module names (`skill-discovery-sources`, `skill-install-filesystem`, etc.)
to clarify dependencies.

- Bind WSL filesystem methods to preserve `this` context
- Keep recovery journal when rollback rename fails, so startup can retry
- Skip symlink-based tests on Windows where they cannot run
- Only treat ENOENT/ENOTDIR as empty directories; propagate other errors
- Fix cross-platform path parent calculation to handle drive roots
- Replace shared constant with localized string for user-facing message
- Use `runProcess` for WSL integration test instead of bare `execFile`

* Add batch limit for skill deletion and improve host availability checkin

- Limit concurrent deletions to prevent remote host overload
- Add retry logic for capability probing to handle transient unavailability
- Add reprobe() method to recheck capability after errors or user refresh
- Fix status logic: receipt cleanup is best-effort, completion depends only on content removal
- Improve error message for unreachable hosts
2026-08-25 03:58:48 -07:00
Neil 4218d5068e fix(cli): seed nvm's default version, not the newest install (#16420)
* fix(cli): seed nvm's default version, not the newest install

#16314 stopped the login-shell probe inheriting the seeded PATH, but left the
seed itself picking the newest installed nvm version. That ordering decides
which node a CLI runs under whenever the probe does not land — a timeout, or a
login shell whose rc never initializes nvm — and newest is precisely the wrong
guess: it is usually the version the user just added and has installed nothing
into. That is the root cause reported in #10932.

Resolve `alias/default` instead, mirroring nvm: follow the alias chain
(`default` -> `lts/*` -> `lts/krypton` -> a version), resolve a partial version
like `24` to the highest matching install, and treat `system`/`node`/`stable`
as no preference. The chain is bounded and cycle-guarded because nvm's own
resolver tracks seen aliases and hand-edited files can point at each other.

Ordering is a preference, not a restriction: the remaining versions stay behind
the default, so a CLI installed outside it is still reachable.

Measured on a real machine with nvm default=24 and a bare v26.7.0 installed:
the old resolver seeds v26.7.0/bin (no CLIs), the new one seeds v24.18.0/bin
(every CLI). Tests were written first and verified to fail on the three bug
cases against main before the fix existed.

Also raise the probe budget from 5s to 10s. The old value was never measured
against a real profile: a bash -ilc loading nvm, rvm, conda and gcloud takes
~1s idle but 6-7s on a loaded machine, so a cold start under load silently
fell back to the seed. Startup does not block on the probe, and the one
awaited consumer is agent detection, which is better served by a probe that
finishes late than one that gives up early.

* fix(cli): reject non-version alias tokens instead of matching v0.x

Review finding, and a real bug I introduced. parseVersionSegment coerces
every unparseable segment to 0, so an unresolvable default alias — `garbage`,
`iojs`, `lts/nonexistent`, any hand-named alias — became [0] and prefix-matched
a `v0.12.x` install, or any stray non-version directory. Orca would then seed a
decade-old node as the preferred runtime. Real nvm answers N/A for all of them.

The `wanted.length === 0` bail could never have caught this: ''.split('.') is
[''], never empty. Replaced with a shape check that still admits legitimate
numeric prefixes — verified against nvm itself, which resolves `24` to
v24.18.0 and `0` to an installed v0.x while answering N/A for the rest.

Also corrects two comments that no longer described the code: the seed is no
longer "newest install", and the probe budget note claimed startup never blocks
on hydration, which is false on packaged Windows where it gates terminal
services and git. The traversal-guard comment claimed a containment join()
already normalizes away; the real guarantee is that matchNvmVersion can only
return an entry of the versions directory.

* fix(cli): match nvm's version-token grammar, not just its first character

Round-2 review finding, and the same bug one layer down. The previous guard
anchored only the first character, but parseInt stops at the first non-digit,
so `0x18`, `00` and `0abc` still parsed to [0] and prefix-matched a v0.12.x
install — the decade-old-node seed the earlier fix was supposed to close.

Reachable: `nvm alias default 0x18` warns that the version does not exist and
writes the alias anyway, then resolves it to N/A.

Use nvm's actual grammar, leading zeros included — nvm calls `00` and `024`
N/A while parseInt reads them as 0 and 24. Verified by executing 17 tokens
against a five-version fixture: every one now agrees with nvm, including the
legitimate prefixes `0`, `0.12`, `24` and `v24.18.0`.

Also drops a dead disjunct (the hop bound already caps the loop, so seen.size
can never exceed it) and corrects the log comment in index.ts, which still
told the reader a failed probe leaves the newest install in front. It leaves
the default version in front now, which is usually survivable but still not
what the shell would have resolved.

* test(cli): skip the lts/* chain fixture on Windows

Round-3 review finding. makeNvmHome materializes each alias as a real file,
and the chain case uses nvm's actual `lts/*` alias — `*` is a reserved Win32
filename character, so writeFileSync fails with EINVAL. PR CI runs a Windows
allowlist that excludes this file, so the breakage only reaches a Windows
developer running the suite locally.

Skipped rather than renamed: `lts/*` is the alias nvm really ships, and the
assertion pins platform: 'darwin' anyway, so the real name costs no coverage.
Matches the skipIf convention already used across src/shared.

Also reflows a comment line that a previous edit ran to 143 characters;
oxfmt does not reflow comments, so nothing would have caught it.
2026-08-25 03:05:24 -07:00
NeilandNeil 822087c8ec refactor(git): split runner.ts into focused command-runner modules (#16395)
* refactor(git): split runner.ts into focused command-runner modules

* chore(ratchets): repoint child_process and wsl.exe allowlists at the split modules

---------

Co-authored-by: Neil <n@example.com>
2026-08-25 02:34:11 -07:00
Neil 1f39c93b01 refactor(ipc): split ssh.ts into focused modules (#16394) 2026-08-25 02:32:21 -07:00
NeilandSeongho.Bak fcf55f2d68 fix(terminal): stop Orca mangling the OMP/Pi title it writes itself (#16381)
* fix(terminal): collapse identity group in the title churn signature

Replaces the ingest-time title rewrite from #16373 with a non-destructive
fix at the actual cause.

The churn suppressor `isDecorativeAgentTitleFrameChange` keyed on the
literal label, so `working:OMP` and `working:Pi` compared unequal and every
alternating frame from a wrapped harness committed a store patch. #16373
made the labels agree by rewriting the stored title to the tab's launch
owner — but `runtimePaneTitlesByTabId` is also the Windows Shift+Enter
byte-encoding input, so normalizing at ingest destroyed evidence other
consumers read (fixed separately in #16376).

Collapse the identity group inside the signature instead. Which member of
a group a frame names is decoration, exactly like the spinner glyph the
signature already strips, so frames compare equal without touching what is
stored. Suppression now changes only WHETHER a frame commits, never WHAT
it says.

Also fixes the flap under a multiplexer (#8032): the collapse runs over
wrapper segments, so "zsh | ⠋ Pi" and "zsh | ⠙ OMP" compare equal, which
the anchored owner-relabel in #16373 never matched.

Reverts the store changes from #16373 and drops the helper it added.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* fix(terminal): fold only bare identity frames into the group token

A legacy "π - <session> - <cwd>" title is Pi-compatible too, so folding
every profile match collapsed two different sessions to the same signature
and suppressed the change outright — reintroducing #16093 through the
churn signature.

Fold only exact bare identity frames, matched per wrapper segment, so
semantic session titles keep comparing on their own text.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* docs(terminal): correct the flap diagnosis in the repro header

Verified against the OMP source: it emits only π-glyph frames
(`DEFAULT_TERMINAL_TITLE = "π"`, title-generator.ts:25), and on an
Orca-hosted pane its native titler cedes to Orca's own injected extension,
which writes `⠋ π - <session> - <cwd>`.

So OMP emits neither "OMP" nor "Pi". Both flap sides are Orca's:
"OMP" from driveSyntheticTitleFromHook, "Pi" from normalizeTerminalTitle
collapsing our own extension's output to a hardcoded literal.

The prior header credited the wrapped harness for frames it never sends,
which is the same wrong narrative that produced eight fixes at eight
layers. No behavior change.

* fix(terminal): stop Orca mangling the OMP/Pi title it writes itself

Verified against the OMP source: it emits only π-branded frames
(`DEFAULT_TERMINAL_TITLE = "π"`, title-generator.ts:25), and on an
Orca-hosted pane its native titler cedes to Orca's OWN injected extension,
which writes `π - <session> - <cwd>` / `⠋ π - <session> - <cwd>` at 80ms.

So neither flapping string came from OMP. Orca made both:
  "Pi"  — normalizeTerminalTitle collapsing our extension's output to a
          hardcoded literal, discarding the session name and cwd (#16093)
  "OMP" — driveSyntheticTitleFromHook injecting over it every 80ms

Fixed at the source:
- normalizeTerminalTitle canonicalizes only the rotating braille frame and
  keeps the rest, in both spinner positions and through a multiplexer
  prefix (#8032). Status still round-trips through normalization.
- detectAgentStatusFromTitle reads the π state separator, so `π ! <label>`
  is permission instead of the blanket idle that hid a blocked agent.
- normalizeCompatibleAgentTitleForOwner swaps only the brand for the
  owner's label, so a pane still reads as its launch owner (#6689, #7633,
  #9077) without losing the session text.
- pi/omp set synthesizeWorkingTitle: false — the agent animates its own
  working title. Terminal states still synthesize; they carry the pane's
  agent identity downstream.

Reverts the ingest-time title rewrite from #16373, whose normalization of
runtimePaneTitlesByTabId also changed Windows Shift+Enter bytes (#16376).

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* fix(terminal): match the state separator only in exact profile casing

The separator check runs on every title, so `omp - deploy notes` and
`pi - refactor the parser` read as an idle agent. The owner rewrite only
ever emits the exact profile labels, so dropping case-insensitivity keeps
`OMP - tmp` classifying while ordinary prose stops matching.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* test(terminal): pin one real OMP turn to two committed patches

Drives 30 working frames as Orca's injected extension emits them plus the
idle transition, and asserts what survives the churn gate. Before the fix
every frame alternated "⠋ Pi"/"⠋ OMP" and each one committed — ~12 store
patches per second on a working tab.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* fix(terminal): carry the permission guard inside the separator reader

`-` is both a π state separator and the delimiter in the synthetic
permission label, so `OMP - action required` read as idle. It resolved
correctly only because detectAgentStatusFromTitle happens to check the
synthetic label first — and the separator fn is exported, so a direct
caller inherited the bug.

Also pins the owner rewrite's fixed-point property, which holds only
because getAgentLabel does not tokenize omp/pi, and corrects a comment
that overstated how tightly the brand swap is scoped.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

* docs(terminal): name the flag the code actually sets

The suite header cited `synthesizeTerminalTitle: false`; the profiles set
`synthesizeWorkingTitle: false`. The distinction is the whole reason the
narrower flag was chosen — terminal-state frames still carry the pane's
agent identity downstream — so the wrong name buried the rationale.

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>

---------

Co-authored-by: Seongho.Bak <49228032+psh4607@users.noreply.github.com>
2026-08-25 02:28:08 -07:00
Neil 33587fb77d refactor(git): split status.ts into source-control modules (#16393) 2026-08-25 02:24:11 -07:00
Neil 127fa7fae0 refactor(ipc): split repos.ts into focused modules (#16392)
* refactor(ipc): split repos.ts into focused modules

* test: point repo notification mocks at the extracted module

* fix(ipc): repoint the child-process allowlists after the repos split

The type-only `import type { ChildProcess }` moved from repos.ts to
repos/repo-clone-lifecycle.ts, so the import-boundary entry follows it and the
windows-console entry (now stale, and that list only shrinks) is dropped.
Fixture-only; the base file had no runtime child_process use at all.
2026-08-25 02:23:53 -07:00
Neil e7c5263a93 perf(git): make local Git metadata observation event-driven (no scheduler) (#16404)
* perf(git): make local Git metadata observation event-driven

Replaces the recurring per-repo metadata scan with native filesystem events on
macOS, Linux, and Windows. Polling is retained purely as a fallback.

- Narrow @parcel/watcher stream over <common>/worktrees, extended from macOS to
  Linux and Windows, with the Windows backend pinned explicitly.
- New shallow watcher mode over the allowlisted primary metadata leaves. It
  watches the containing directory rather than each file, so Git's atomic
  write-and-rename does not orphan the binding.
- Selected upstream refs stay on the existing bounded stat poll.

Verified on real hosts rather than in principle:

- Windows: `git worktree remove` and `git worktree prune` both succeed while the
  narrow stream holds the directory. The historical concern that an open handle
  would block prune does not reproduce.
- Linux: inotify costs one instance per event loop, not one per watch, so the
  watch budget is not a constraint.
- macOS/Linux/Windows: shallow events survive repeated commit, checkout,
  config, and pack-refs cycles.

Failure handling, each reproduced before being fixed:

- fs.watch binds an inode and reports nothing once that inode is replaced, with
  no error. Directory bindings are re-checked on a bounded cadence and rebound.
- A host whose notification path is dead accepts registrations and stays mute
  forever. Observed on a macOS machine whose fseventsd had grown to ~15GB and
  saturated a core. A one-shot delivery probe now fails the shallow subscribe on
  such a host so it falls back to polling instead of showing stale metadata.

This change stands alone on main and does not depend on the metadata poll
scheduler.

* test(git-watch): hold reserved inodes across root replacements

Linux returns a released reservation to the free list, so the second
replacement could land back on the first replacement's inode and look
unchanged to reconciliation. Verified on ext4: releasing yields inodes
[N, N+43, N+43] while holding yields [N, N+43, N+44]. macOS never
recycles, which is why this only failed on CI.

* refactor(git-watch): share one single-flight helper between watcher fallbacks

Both fallbacks tracked their in-flight promise with the same self-comparison
on settle, duplicated verbatim. Hoisting it removes a subtle invariant that
was being hand-maintained in two places.

* fix(git-watch): close the silent-staleness paths in primary metadata

Two independent reviews converged on the same root cause: nothing bounded
how long primary metadata could stay wrong once the shallow watcher stopped
reporting. Four distinct paths led there.

- A terminal watch error arriving while the status-ref poll was still starting
  left the repo with status-ref coverage only. handleWatcherError ran its
  teardown against nulls, then the in-flight poll installed itself, and the
  fallback guard mistook it for coverage and discarded the fallback. Primary
  metadata was then never observed again. The guard no longer treats status-ref
  polling as primary coverage, and startup re-checks watcher liveness after its
  awaits.

- Nothing re-read the six primary files while the watcher was nominally live.
  A lossy notification path, a dropped batch, or inotify queue overflow raises
  no error, so the error-driven fallback never fired and the inode rebind sweep
  does not detect loss. A 15-tick backstop re-stats them, turning permanent
  staleness into one tick. Measured cost is ~0.2 stats/s/repo against the 3/s
  the old poll cost.

- Reconciliation treated any late-observed entry create as a root replacement,
  so an ordinary  tore down a healthy stream ~30s later and
  opened a deaf window. It now also requires the root itself to be recreated.

- The shallow watcher recorded directory identity from a stat issued after
  binding, so a replacement in that gap pinned the dead inode's watcher to the
  new identity and the sweep would never rebind. Identity is now read first,
  which errs toward a harmless extra rebind.

Test helper: replacing the worktrees root frees several inodes at once, so
reserving one still let the recreated root reuse its own. It now verifies the
inode actually changed. Confirmed on ext4, where three holds were needed.
2026-08-25 01:22:07 -07:00
Neil d456549c22 refactor runtime Git bridges (#16189)
* refactor runtime git bridges

* fix(test): avoid computed namespace import access in runtime Git client contract test
2026-08-25 00:54:43 -07:00
Neil fc0d947983 refactor(rate-limits): split Codex and Claude fetchers (#16140)
* refactor(rate-limits): split Codex and Claude fetchers

* refactor(rate-limits): restore base error-message defaulting

The split moved the 'Unknown error' fallback from inside String() to the call site, which changed behavior for an Error with an empty .message: base surfaced '', head surfaced 'Unknown error'. Restore the base form.
2026-08-25 00:53:44 -07:00
Neil 83ffc0df24 refactor Electron facilities modules (#16333)
* refactor oversized Electron facilities

* fix interactive process timeout and shortcut repeat guard

* chore(child-process): drop stale cli-installer allowlist entry

cli-installer.ts now routes privileged spawns through runProcess via
cli-privileged-processes.ts, so the shrink-only ratchet flags it as stale.

* refactor(child-process): extract the bounded output sink

runProcess's timeoutMs opt-out (required to preserve the unbounded osascript
admin prompt) pushed run-process.ts past the 300-line cap. Move createOutputSink
to its own module rather than add a max-lines bypass, which AGENTS.md forbids.
Moved verbatim; no behavior change.
2026-08-25 00:31:31 -07:00
Neil 1cf562deea refactor: split source control AI modules (#16179) 2026-08-25 00:31:06 -07:00
Neil 6103b78e3c fix(crash-reporting): correlate concurrent process deaths instead of blaming the renderer (#15251)
* fix(crash-reporting): correlate concurrent process deaths on a renderer report

Two 1.4.184 reports (a326935a, 1862f316) are renderer "crashed"/-1 crash reports
whose renderer only died alongside a sibling Chromium child that died at the same
instant:

  F0BQMB30GJX  network.mojom.NetworkService crashed/-1  -21ms -> renderer crashed/-1
  F0BRPP8TC0Y  audio.mojom.AudioService     crashed/-1   -2ms -> renderer crashed/-1
                                                  GPU crashed/-1 +180ms

process-gone-classification.ts classifies each event in isolation:
isRecoverableChromiumChildProcess discards the utility/GPU halves as recoverable
churn, and `if (reason !== 'killed') return true` then reports the renderer half as
a genuine renderer crash before any cross-source signal exists. Triage reads
"renderer crashed" for what died with three other processes.

process-gone-sibling-correlation keeps a bounded ring of child deaths, populated
before the suppression early-return so churn-suppressed siblings stay visible, and
matches a renderer death against child deaths sharing its failure signature.

What the timing can and cannot support:

- The window is asymmetric. 1s of lookback (a child that died first can plausibly
  have taken the renderer with it), but only 250ms of lookahead: a child dying well
  after the renderer is at least as likely to be an effect of it — Chromium tearing
  down the dead renderer's channels, or renderer_recovery_reload at +264ms — and a
  symmetric window would retro-label a genuine lone crash as collateral.
- crashAttribution is 'concurrent-process-deaths', not a causal claim. The largest
  1.4.184 cluster is an external taskkill /T where renderer and children are
  co-victims; no sibling caused anything there.
- The verdict is not derived from timing alone. A host with a child looping at the
  observed 1459/min drops a death into every window, so crashAttribution is set only
  when the nearest sibling is within 250ms and no identity repeats. Looser or
  repeating deaths still ship as evidence (siblingProcessDeathCount, signed offsets,
  siblingProcessDeathRepeats) with no attribution.
- The signature match buckets `crashed` with `abnormal-exit` and gates on the exit
  code only on win32. Both fixtures are win32, where every process in a collateral
  pair reports crashed/-1; POSIX surfaces a per-process wait status, so an equality
  gate would mean this never fires on macOS or Linux.

The report stays reportable and gains evidence rather than being suppressed
(#14667). Both arrival orders are covered without delaying persistence: a sibling
that dies first is folded into the initial record, a sibling that dies after amends
the record already on disk through attachDetails, the same way the minidump
signature does. Late amends are capped at two per report and skipped when the
rendered evidence is unchanged, so a crash-looping child cannot rewrite the store
during renderer recovery, and a failed amend now leaves a
sibling_attribution_attach_failed breadcrumb instead of vanishing.

Relationship to #12484: it is still OPEN and adds process-tree-kill-window.ts, the
same ring/lookback bookkeeping with a 250ms settle, patching the same recorder
hunks with the opposite policy (suppress the killed/1 renderer report instead of
keeping it). #14667 is test-only — it pinned the keep-the-report policy in tests, it
did not remove a shipped implementation. #12484 has to be closed or rebased out
before this lands.

* chore: remove merge hook formatting drift
2026-08-25 00:11:05 -07:00
Neilandinnocarpe 49acb93e2e fix(daemon): let PTY inventory recover from a dead terminal host (#16363)
Worktree removal inventories PTYs through DaemonPtyAdapter.listProcesses. That
called ensureConnected bare, so once the terminal-host pipe was dead the
removal failed with `connect ENOENT \\?\pipe\orca-terminal-host-...` and stayed
broken until the whole app was restarted.

spawn already wrapped its work in withDaemonRetry and recovered from exactly
this. Inventory did not — so the one path that must not get stuck was the only
one that could not heal itself.

Both the connect and the listSessions request go inside the retry: a host that
dies between them throws the same daemon-gone error, so retrying only the
connect would still fail. The reconciliation after the request is deliberately
outside it; retrying that would be wrong.

Reproduced first, with a real daemon killed mid-test: listProcesses threw
DaemonConnectionLostError while a control asserting spawn recovery from the
identical kill passed. Both are now regression tests, so the asymmetry cannot
come back silently.

Co-authored-by: innocarpe <innocarpe@users.noreply.github.com>
2026-08-24 23:57:43 -07:00
deae7212d9 fix(orchestration): derive inject's agent guidance from the recognized-agent roster (#15874)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: vam <a@a.com>
2026-08-24 23:32:30 -07:00
Neil 48e63c015f refactor agent config and auth services (#16195)
* refactor: split agent config and auth services

* chore: repoint wsl and global-fetch guards at split module paths

* fix: restore merge-base Claude CLI error propagation

Drop the secret-redaction rewriting added to Claude CLI error paths in the
refactor: spawn errors again reject with the original Error (preserving
.code/.errno/.syscall/.stack) and command output/auth-status logs are no
longer rewritten.
2026-08-24 23:15:01 -07:00
Neil 2b1b094aa8 fix(cli): pair every resolved CLI with its runtime, and ratchet it (#16383)
Follow-up to #16365, which paired 8 spawn sites by hand. Hand-pairing is how
the class got introduced, so close it structurally instead.

cliPath is now required on CodexAppServerInvocation, `null` only for the
guest-side wsl.exe launcher where a host path pairs nothing. Optional let a
native builder omit it and silently fall back to pairing against a cmd.exe
wrapper with no type error. Every production site already passed it; only
test fixtures needed updating, which is the type doing its job.

Four more sites now pair. codex-state-db-backfill-recovery spawns the same
`codex app-server` subcommand #16365 fixed elsewhere. cli/handlers/account
was the worst case: addAgentNodePaths prepends the *newest* version-manager
bin, which is not necessarily where the CLI being launched lives, so it
actively created the mismatch — pairing now runs last so the CLI's own node
wins. commit-message-text-generation and skills/skill-update-run spawn
resolved binaries with inherited env.

cli/handlers/skills had grown its own buildNpxPath: a weaker local copy that
prepended unconditionally, ignored the Windows `Path` key, and special-cased
a '.' dirname. Deleted in favor of the shared helper, which checks the
sibling node actually exists — the behavior change one test had pinned.

The ratchet is the point: any file that resolves a CLI and spawns must
reference withCliRuntimeOnPath, with a shrink-only allowlist. It caught
skill-update-run, which I had missed. Its first draft required a call paren
and so let dependency-injected resolvers (`resolveCommand: resolveCodexCommand`)
through — verified by removing a pairing and watching it stay green, then
widened until it failed. A second assertion fails on a stale allowlist entry
so an exemption cannot outlive its reason.

external-editor-launch stays allowlisted: it launches a GUI editor, not a
Node CLI whose ABI matters.
2026-08-24 23:12:37 -07:00
Neil 20c1a61401 fix(orcad): answer host paths honestly and ship the watcher child (#16369)
orcad's AppEnvironment implemented three of seven AppPathNames and returned the
userData directory for the rest — including 'exe', where a data directory is not
an executable. Every name now has a Node answer: 'appData' is the platform's
per-user application-data root, 'logs' lives inside the data root so a headless
deployment stays one removable directory, 'downloads' honours XDG_DOWNLOAD_DIR,
and 'exe' is the Node binary. getAppPath() is the directory orcad was launched
from rather than cwd, so children resolve against the bundle instead of wherever
the supervisor happened to be.

The watcher child was the load-bearing consequence: resolveWatcherProcessEntryPath
probed for the adjacent entry only when !isPackaged, so orcad resolved a desktop
out/main path that no deployment has — and build-orcad never emitted the child
anyway. isPackaged stays true (consumers read it as "production, not a dev
checkout" and it gates HTTPS-only skill downloads); the resolver now asks whether
the app root is an asar archive, which is the question it actually meant. The
child ships beside orcad.js, and the build forks it to prove it runs.
2026-08-24 23:01:17 -07:00
Neil c4988fa81c refactor workspace lifecycle modules (#16334)
* refactor(workspaces): split lifecycle modules

* preserve workspace cleanup consent contract

* restore workspace delete shortcut hint in context menu view

* restore host-qualified visit recency and viewed-candidate predicate

* test(cleanup): pin the viewed-mark upgrade path and host-qualified visit reads

Two invariants a refactor broke in this PR, both silent:

- viewed marks are persisted, so gating `shouldPreserveCleanupInspection` on any
  newer field voids the grace period for every entry written by an older build
- visits are stamped under `${hostId}|${worktreeId}` whenever the host is known
  (the normal case, including 'local'), so a bare map[worktreeId] read misses
  every modern entry and yields 0, disabling the recent-visible-context blocker

Verified discriminating: reintroducing each bug fails exactly its own test.
2026-08-24 22:44:08 -07:00
Neil a7505fd911 fix(cli): spawn a version-manager CLI with its own node runtime (#16365)
* fix(cli): spawn a version-manager CLI with its own node runtime

resolveCliCommand falls back to scanning every version-manager install when
PATH misses, so it can hand back ~/.nvm/versions/node/v20.x/bin/codex while
PATH still leads with v22. Nothing paired the binary with the runtime it was
installed against, so its `#!/usr/bin/env node` shebang loaded a v20-built
native module under a v22 ABI and the agent died on first require (#10932).

Reproduced with a real addon rather than asserted: a CLI requiring a
cpu-features build for NODE_MODULE_VERSION 115, spawned with v24 leading
PATH, fails with ERR_DLOPEN_FAILED and exit 1. With the CLI's own bin
directory prepended it runs clean.

withCliRuntimeOnPath prepends the resolved command's directory when that
directory ships a sibling node, and is a no-op otherwise — so a Homebrew or
/usr/local CLI is untouched, and the WSL paths pass a bare `codex`/`claude`
that is not absolute and so never matches.

Host CLI resolution in the Claude login path is now lazy, keeping the WSL
branch from resolving a host binary it never spawns.

* fix(cli): split PATH on the delimiter we join with, pair app-server too

Readiness review findings, all four addressed.

withCliRuntimeOnPath chose its join delimiter from the platform option but
split with the host's. Passing platform:'win32' from a posix host turned
`C:\Windows;C:\Windows\System32` into `C;\Windows;C;\Windows\System32` —
every drive letter torn off at its colon. Latent, since no shipped caller
passes platform, but the sole win32 test was written against the corrupted
value and asserted one split segment, so it green-lit the shredding.

That test's other assertion was vacuous: it seeded only `Path`, so the
`PATH` key it asserted absent could never exist. Deleting the whole
case-dedupe block left the suite green. It now seeds both keys and asserts
the full joined string; removing the block fails it.

Nothing covered the wiring, and the argument choice is the easy thing to get
silently wrong. Note it only diverges on win32 — on posix
getSpawnArgsForWindows returns the CLI itself, so pairing the spawn command
is indistinguishable there. The new test drives the win32 branch with a .cmd
fixture; pairing spawnCmd or dropping the wrapper both fail it now.

codex-trust-grant-host and codex-session-index-heal spawn the same
`codex app-server` subcommand through runCodexAppServerSession and were left
unpaired. Pair centrally there via a new optional cliPath, since
invocation.command may be a cmd.exe wrapper.

Pairing tests live in their own file: adding them inline pushed
codex-fetcher.test.ts past the 800-line ratchet.

* fix(cli): read the Windows path key the child will actually use

Round-2 review finding. The read was narrower than the delete: the key was
picked from exactly two spellings (`Path`, else `PATH`), while the twin
dedupe removed every key whose lowercase form is `path`. A block spelling it
`path` or `pATh` therefore had its value deleted without ever being read,
handing the child a PATH containing only the CLI's own directory — a strictly
worse outcome than not pairing at all.

Win32 resolves env names case-insensitively and object order preserves block
order, so the entry the child reads is the first case-insensitive match. The
repo already encodes that rule in resolvePathEnvKey
(src/main/pty/windows-path-segment-merge.ts); src/shared cannot import from
src/main, so mirror it locally.

Verified by execution across six env shapes: lowercase, mixed-case, Path-only,
PATH-only, both twins, and a PATHEXT control that must not be touched. All
preserve the original PATH; before the fix the first two lost it entirely.
Reverting the selector fails the new test and nothing else.
2026-08-24 22:30:04 -07:00
Neil e217fdd10f build(orcad): gate orcad's own graph, and prove it loads under plain Node (#16368)
* fix(orcad): close the browser-provider gaps

The providers landed without enforced coverage, so a regression in either path
would have landed silently.

- CI: the external-Chromium integration test was gated on ORCA_BROWSER_EXECUTABLE
  and nothing ever set it, so it skipped forever. It now runs in its own job
  against the runner's Chrome and FAILS when Chrome is absent rather than
  skipping, because an unset variable is exactly how it went uncovered. Timeout
  raised to 120s: a warm run is ~7s but the first launch against an unseeded
  profile took 30s and hit Vitest's default, and CI is always that cold case.
- Electron provider had no test at all. It is the path anyone with the desktop
  app hits.
- Browser unavailability reported one message for four causes, including telling
  an operator to set a variable they had already set.

Fixes a live defect found while covering it: the runtime advertises
browser.tabCreate.known-id.v1 unconditionally, so a web client sends a
provisional page id for a page that does not exist yet — and the sidecar's
generic requestedPageId branch ran require() on it first and threw. Every
known-id create against the Electron provider failed. The adoption logic was
already there; only the ordering was wrong.

Also updates the workflow-parallelism guard, which correctly caught the new job
missing from verify's required-check list, and asserts verify actually reads it.

* build(orcad): gate orcad's own graph, and prove it loads under plain Node

Two gaps the artifact's own comment asked for.

The ratchet measured only orca-runtime + runtime-rpc, but orcad imports ipc/pty
directly to install the PTY controller, so its graph is strictly larger. The gate
could read zero while the shipped artifact regressed. orcad's entry is now a
ratchet entry point, and the baseline stays empty with it included.

orcad cannot join plain-node-entry-guard — that is a rollup plugin keyed on
electron-vite input names, and orcad is an esbuild artifact. But the half that
matters here is the guard's smoke-load: scanning the metafile proves no module
NAMES electron, not that the graph resolves under plain Node. A dynamic require,
a missing native or a top-level throw all pass the scan and fail at runtime.
build-orcad now runs the bundle with a bogus flag and requires the argv rejection
that only a fully loaded graph can produce.

Verified: a bundle that builds but throws on load fails the gate.
2026-08-24 22:21:26 -07:00
Neil 8043b5f705 Refactor worktrees IPC into cohesive modules (#16190)
* refactor worktrees IPC into cohesive modules

* test update PTY waiver invariant paths

* revert unrelated oxfmt reflow from merge commit
2026-08-24 21:19:11 -07:00
Neil d8807801ff Refactor GitHub Project surfaces and mutations (#16166)
* refactor github project modules

* restore dropped issue #4756 guard comments
2026-08-24 21:18:55 -07:00
Neil 087b895524 refactor(daemon): split oversized PTY services (#16160)
* refactor(daemon): split oversized PTY services

* revert(daemon): restore merge-base session listing and canceled-spawn behavior

Two behavior changes rode along with the file-splitting refactor:

- listLiveTerminalHostSessions dropped sessions with isTerminating, not just
  dead ones, hiding sessions the merge base still advertised.
- spawnAndPublishSession called session.beginTermination() before publishing a
  canceled spawn into the host map.

Both hunks are reverted to the merge base; the refactor is untouched.
2026-08-24 21:18:11 -07:00
Neil 09048c63d4 feat(orcad): add headless browser providers (#16193)
* feat(orcad): add headless browser providers

* fix(orcad): merge the duplicate runtime-browser type import
2026-08-24 21:11:45 -07:00
Neil 788575e300 fix(crash-reporting): stop destroying user crash notes (#15252) 2026-08-24 21:03:34 -07:00