Merge origin/main into external-worktree terminal teardown

main refactored the detected-worktree refresh into a lease/admission model
(DetectedWorktreeRefreshOutcome, directSshAuthority, mergeFetchedWorktrees).
Reapplied the per-caller teardown on top of that structure:

- carry connectionId/knownWorktreeIds on DetectedWorktreeRefreshOptions
- request teardown before every 'admitted' return (runtime and lease paths),
  so a caller that joined a coalesced scan still stops its terminals
- knownWorktreeIds is optional: absent means "nothing to reconcile", never
  "purge everything"
- update the three teardown tests to main's host-qualified listDetected mock

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-28 15:57:59 -07:00
co-authored by Orca
652 changed files with 48793 additions and 7355 deletions
+5 -1
View File
@@ -2,6 +2,10 @@ name: E2E
run-name: E2E ${{ inputs.ref || github.ref }}
# Why: checkout + artifact upload only; callers can only further restrict.
permissions:
contents: read
on:
workflow_call:
inputs:
@@ -78,7 +82,7 @@ jobs:
name: e2e ${{ matrix.shard_name }}
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
+62 -8
View File
@@ -32,8 +32,11 @@ jobs:
- name: Lint
run: pnpm exec oxlint --format github
- name: Check switch exhaustiveness
run: pnpm run lint:switch-exhaustiveness
- name: Enforce focused code-quality plugins
run: pnpm run audit:code-quality:native
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
- name: Enforce changed-code quality
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
@@ -44,12 +47,6 @@ jobs:
- name: Check Zustand selector fan-out budget
run: pnpm run check:zustand-selector-fanout
- name: Check styled scrollbars
run: pnpm check:styled-scrollbars
- name: Check quadratic buffer concatenation
run: pnpm run check:quadratic-buffer-concat
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
@@ -268,6 +265,55 @@ jobs:
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
# Why: regression specs under tests/e2e/** used to merge green without ever
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
e2e-paths:
name: detect e2e path changes
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
# Why: detector only needs to read the checkout; do not inherit repo defaults.
permissions:
contents: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- name: Filter E2E-relevant paths
id: filter
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Why: capture first so a failed git diff does not look like "no matches"
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
# list to files this PR introduced, not base-branch drift.
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
# file and would silently skip E2E when the runner config changes.
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "E2E path changes detected"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "No E2E path changes"
fi
e2e:
name: e2e
needs: e2e-paths
if: needs.e2e-paths.outputs.should_run == 'true'
# Why: reusable e2e.yml only checkouts, builds, and uploads artifacts.
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
verify:
if: always()
needs:
@@ -280,6 +326,14 @@ jobs:
runs-on: ubuntu-latest
steps:
# Why: e2e is deliberately absent from needs. The suite is currently red on
# main (every scheduled run), so gating merges on it would block any PR that
# touches tests/e2e/** — including the ones fixing the suite. Until it is
# green the job runs and reports for E2E-path PRs without blocking. To flip
# it on: add `e2e` to needs, add E2E to the env below, and require
# `"$E2E" = success || skipped` after the loop — skipped is the normal
# result for a path-filtered job and must keep passing, so it has to be
# checked outside the loop or it would excuse the jobs above.
- name: Require successful checks
env:
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
+30
View File
@@ -5,12 +5,29 @@
{
"name": "mobile-pairing",
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
},
{
"name": "app-store-performance",
"specifier": "./config/oxlint-plugins/app-store-performance.mjs"
},
{
"name": "quadratic-buffer-concat",
"specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs"
},
{
"name": "renderer-scrollbar-style",
"specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs"
}
],
"categories": {
"correctness": "error"
},
"rules": {
"app-store-performance/require-selector": "error",
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
"no-fallthrough": "error",
"quadratic-buffer-concat/no-loop-carried-concat": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
@@ -93,6 +110,19 @@
"unicorn/throw-new-error": "error"
},
"overrides": [
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error"
}
},
{
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
"rules": {
"quadratic-buffer-concat/no-loop-carried-concat": "off",
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
}
},
{
"files": ["**/*.ts"],
"rules": {
-604
View File
@@ -1,604 +0,0 @@
# Orchestration Structured Worker Output
Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete
Scope: orchestration `worker-read` only
Last updated: 2026-07-24
## Summary
`orca orchestration worker-read` currently reads bounded terminal output. That is always available,
but full-screen agent TUIs can make it noisy or incomplete.
Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with:
- the agent type, such as Codex or Claude;
- the provider session or conversation ID; and
- when available, the provider-reported transcript path.
The sidebar, session resume, sleeping-agent recovery, and native chat already use this information.
The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the
server where the worker runs.
The proposed behavior is:
```text
worker-read
exact supported transcript is available -> structured transcript page
otherwise -> bounded terminal page
```
This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer.
It makes one existing observation command return the best source Orca can prove.
## User-facing goal
A coordinator should be able to inspect a worker with one predictable command:
```bash
orca orchestration worker-read --dispatch <dispatch-id> --json
```
The coordinator should not need to know:
- which Orca server owns the worker;
- the worker's terminal or pane handle;
- the provider session ID;
- where a transcript lives on disk; or
- whether structured reading is supported by that provider/server version.
The response must always say which source was used. It must never silently read a different agent
session.
## Why the existing sidebar is relevant
The agent logo identifies the detected agent type. By itself, that is not enough to choose a
transcript.
The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks.
That is the useful foundation:
```text
Dispatch
-> exact worker process and terminal
-> exact tab/leaf pane
-> hook-reported provider session
-> provider transcript locator
```
Some live status ownership is currently renderer-centric, while headless and mobile graph paths
also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup
that exposes the current exact pane association to `worker-read`. This is a small bridge over
existing status data, not a second agent-status system.
## Design principles
### Exactness over convenience
- Never select the "latest session in this directory."
- Never select a transcript from a terminal title or logo alone.
- Never switch sources or sessions in the middle of a cursor chain.
- If Orca cannot prove the association, return a labeled terminal fallback.
### Resource-local reads
The server running the worker resolves and reads its transcript. A Run home on macOS must not try
to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path.
Only bounded output data crosses the federation connection. Transcript paths do not.
### One simple agent command
Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to
automatic source selection. Source selection flags exist for debugging and explicit policy, not
because they are required in the normal loop.
### Narrow provider support
Initial support should cover only providers for which Orca already has:
1. an exact pane-scoped session association; and
2. an existing bounded transcript reader with test coverage.
Codex is the required first provider. Claude may ship in the same change only if it uses the same
proven reader path without adding a second architecture. Other agents receive terminal fallback.
### Honest compatibility
Connected servers can run different Orca versions. A server without structured-read support must
continue to return bounded terminal output rather than failing the whole Run.
## Public command contract
### Request
```bash
orca orchestration worker-read \
--dispatch <dispatch-id> \
[--source auto|transcript|terminal] \
[--cursor <opaque-cursor>] \
[--limit <count>] \
[--json]
```
`--source` behavior:
| Value | Behavior |
| ------------ | -------------------------------------------------------------------------------------- |
| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. |
| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. |
| `terminal` | Use the current bounded terminal reader. |
Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can
pin either source without exposing provider paths.
### Response
The implemented structured response has this shape:
```json
{
"dispatchId": "dispatch_123",
"source": "transcript",
"sourceIdentity": "opaque-source-fingerprint",
"provider": "codex",
"transcript": {
"messages": [],
"nextCursor": "opaque-next-cursor",
"limited": false,
"returnedMessageCount": 0
},
"cursor": "opaque-next-cursor",
"status": {
"worker": "running",
"terminal": "running"
},
"fallbackReason": null,
"warnings": []
}
```
Terminal fallback uses the same envelope and keeps the existing terminal data:
```json
{
"dispatchId": "dispatch_123",
"source": "terminal",
"sourceIdentity": "opaque-terminal-incarnation",
"terminal": {
"tail": ["..."],
"status": "running",
"nextCursor": "..."
},
"cursor": "opaque-next-cursor",
"fallbackReason": "session_not_reported",
"warnings": []
}
```
For backward compatibility:
- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and
`terminal.nextCursor` fields;
- non-JSON output prints readable transcript entries or terminal lines without requiring an agent
to branch on JSON manually;
- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as
a labeled terminal response.
### Fallback reasons
Fallback reasons are bounded typed values, not arbitrary policy:
- `provider_unsupported`
- `session_not_reported`
- `transcript_missing`
- `transcript_unreadable`
- `transcript_parse_failed`
- `remote_capability_unavailable`
Warnings may provide safe diagnostic context, but must not contain a remote filesystem path.
### Typed errors
Errors are used when returning any source would be misleading:
- `dispatch_not_found`
- `worker_identity_changed`
- `source_changed`
- `cursor_invalid`
- `cursor_dispatch_mismatch`
- `transcript_required`
- the existing connected-server unavailable/unknown result
`source_changed` means the exact pane is now associated with a different provider session than the
one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently
jumps to the replacement session.
## Source identity and cursor rules
The cursor is opaque to clients and contains only non-sensitive routing data:
- cursor version;
- Dispatch ID;
- source kind;
- an opaque digest of the exact source identity;
- provider-specific or terminal paging position.
It must not contain a transcript path.
On every continued read, the worker server:
1. revalidates the Dispatch's exact process attachment;
2. resolves the current pane/session association;
3. compares its source digest with the cursor;
4. reads only when they still match; and
5. otherwise returns `source_changed` or `worker_identity_changed`.
The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the
first page.
The implementation uses a versioned stateless token. The token is not an authority credential:
every read revalidates the Dispatch, process, pane, source digest, and provider session before
returning data. This lets paging survive an Orca restart without adding cursor-secret persistence.
## Runtime architecture
### 1. Resolve the Dispatch at its Run home
The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its
pinned worker server exactly as `worker-show` and the current `worker-read` do.
No automatic placement or server selection is added.
Current implementation anchors:
- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated
`worker-read` routing.
- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached
worker.
- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering.
### 2. Route the read to the worker server
For a local worker, the Run home and worker server are the same runtime.
For a federated worker, the Run home calls the existing federation read route on the server pinned
to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a
terminal handle, session ID, or transcript path chosen by the coordinator.
### 3. Revalidate the exact worker
The worker server uses the existing Dispatch attachment to verify:
- the exact managed pane;
- the exact terminal/process incarnation; and
- that the Dispatch has not been replaced, stopped, or detached.
This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads.
### 4. Resolve the pane's provider session
Add one runtime-owned resolver that returns a snapshot similar to:
```ts
type ExactWorkerProviderSession = {
paneKey: string
agent: TuiAgent
providerSession: AgentProviderSessionMetadata
observedAt: number
}
```
The resolver may use the current runtime graph/headless retained status, but it must accept the
Dispatch's exact pane identity as input. It must not search all sessions by working directory or
agent type.
The source association is considered usable only when:
- it belongs to the Dispatch's exact current pane/process;
- the hook metadata is fresh enough to belong to that process incarnation;
- the provider session metadata passes existing normalization/canonicalization; and
- a supported adapter can resolve an exact transcript.
If these checks fail under `auto`, the read falls back to terminal output.
Current session/status anchors:
- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata.
- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status.
- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots.
- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those
snapshots.
### 5. Read through a narrow adapter
Reuse the existing bounded native-chat transcript parser rather than adding another parser stack.
Extract or wrap its pure reader behind a small orchestration adapter:
```ts
type WorkerTranscriptReader = {
provider: 'codex' | 'claude' | 'openclaude' | 'grok'
readPage(input: ExactTranscriptRead): Promise<ExactTranscriptPage>
}
```
This is deliberately not a registry for every possible agent capability. Add an adapter only when
an exact locator and a tested reader already exist.
Current reader anchors:
- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions.
- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC.
- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer.
The transcript response should preserve the existing structured message/block representation and
the supported fields that the proven reader already understands. Unknown or skipped input should
produce parsing warnings rather than being silently presented as a complete transcript.
### 6. Return bounded data
Every path enforces:
- at most 50 transcript messages per page (40 by default);
- a maximum serialized response size;
- existing clipping/redaction rules for large tool input and output;
- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens;
- deterministic pagination; and
- no transcript path leakage.
Transcript observation is read-only. A failure or unknown network result must never trigger worker
restart, retry, stop, or Task mutation.
## Federation and cross-platform behavior
The core topology is:
```text
Mac Run home
-> authenticated connected-server RPC
-> Windows worker server
-> exact Windows pane/session
-> Windows-local transcript reader
-> bounded structured page back to Mac
```
The reverse direction must work identically.
Platform rules:
- Use Node path operations only on the server that owns the path.
- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows.
- SSH and WSL execution remain behind their owning Orca server.
- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must
use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the
Run home.
- Mixed-version capability negotiation applies only at the Orca server protocol boundary.
- A server that does not advertise structured worker read receives the existing terminal-read RPC.
Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes
it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing
`orchestration.federationRead` terminal method and wraps that result as a labeled
`remote_capability_unavailable` fallback. No generalized capability matrix is added.
## Lifecycle behavior
### Provider session appears after worker start
Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return
terminal output. A later first-page `auto` read may select transcript output.
Once a cursor is returned, that cursor remains pinned to its selected source.
### Provider session changes
Compaction, resume, or process replacement may produce a new provider session:
- a fresh read without a cursor may select the new exact session;
- a cursor for the old session returns `source_changed`;
- Orca does not merge the old and new transcripts implicitly.
### Orca restart
After restart, the worker server re-establishes the exact pane/process association using the same
runtime graph and retained-hook mechanisms used by sidebar/session recovery.
- If exact identity and session still match, paging continues.
- If process identity is uncertain, return `worker_identity_changed`.
- If only transcript identity is unavailable, `auto` may start a new terminal page but must not
reinterpret an old transcript cursor as a terminal cursor.
### Disconnect
A disconnected federated read is a read-only unknown result. Reissuing the same read is safe.
No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed.
## Implementation plan
### Work package 1 — Correct the contract and types
- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session
association does not exist.
- Add the source preference, response envelope, fallback enums, and opaque cursor types.
- Keep the existing terminal response fields compatible.
- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors.
Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes.
### Work package 2 — Runtime exact-session resolver
- Add a runtime method that resolves agent status for an exact Dispatch pane/process.
- Reuse the existing graph/headless retained status and provider-session normalization.
- Reject stale pane or process-incarnation associations.
- Test multiple panes and multiple sessions in the same worktree and directory.
Exit gate: the resolver can never return a sibling pane's session.
### Work package 3 — Codex transcript adapter
- Reuse the current bounded native-chat Codex reader.
- Add deterministic page conversion and parsing warnings.
- Enforce entry and byte limits.
- Produce a path-free source identity digest and cursor.
- Add Claude only if it follows this same path without new infrastructure.
Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path.
### Work package 4 — Local `worker-read`
- Resolve and validate the exact worker.
- Implement `auto`, `transcript`, and `terminal`.
- Pin the source across cursor pages.
- Preserve the existing terminal fallback and non-JSON rendering.
Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex
sessions.
### Work package 5 — Federated `worker-read`
- Add the narrow connected-server capability.
- Route transcript resolution and reading to the worker server.
- Wrap legacy remote terminal responses as labeled fallbacks.
- Reject mismatched Dispatch/server/session cursors.
- Ensure paths and internal server identity remain out of ordinary output.
Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass.
### Work package 6 — Restart, fallback, and documentation
- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable
transcript, unsupported providers, and mixed server versions.
- Update CLI help, the orchestration skill, and the implementation checklist.
- Dogfood the common coordinator loop using only the documented commands.
Exit gate: every fallback is truthful and no fallback changes worker lifecycle state.
## Validation plan
### Unit and contract tests
| Area | Required proof |
| --------------------- | ------------------------------------------------------------------------------------ |
| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. |
| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. |
| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. |
| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. |
| Cursor pinning | Continued pages stay on the same source and provider session. |
| Session replacement | An old cursor returns `source_changed`. |
| Cursor custody | A cursor for another Dispatch is rejected. |
| Parsing | Malformed/skipped transcript records produce bounded warnings. |
| Limits | Entry count, block size, and total serialized response are bounded. |
| Privacy | Responses and cursors contain no transcript path. |
| Compatibility | Existing terminal fields and numeric cursors continue to work. |
Likely focused test locations:
- provider-session normalization and pane association tests;
- native-chat transcript reader tests;
- orchestration worker-control RPC tests;
- orchestration worker CLI tests; and
- federation protocol and physical harness tests.
### Local integration scenarios
1. Start a Codex worker and confirm the sidebar reports its provider session.
2. Read the Dispatch and verify `source=transcript`.
3. Start two Codex workers in the same worktree.
4. Give them distinct prompts and verify neither read contains the other's content.
5. Page both transcripts and verify stable source identities.
6. replace or resume one session and verify its old cursor returns `source_changed`.
7. Disable hooks and verify a labeled terminal fallback.
8. Remove or make the transcript unreadable and verify a safe fallback or
`transcript_required`, depending on the requested source.
### Physical federation matrix
| Run home | Worker server | Worker location | Required outcome |
| -------- | ------------- | --------------- | --------------------------------------------------------------- |
| macOS | Windows | native Windows | Exact structured page or labeled supported fallback |
| Windows | macOS | native macOS | Exact structured page or labeled supported fallback |
| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage |
| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage |
For both Mac/Windows directions:
- run multiple workers at once;
- page beyond the first response;
- restart the Run home;
- restart the worker server;
- disconnect and reconnect the server;
- verify mixed-version fallback with one server lacking the new capability; and
- compare the selected provider session with the sidebar/native-chat session for the same pane.
### Dogfood procedure
The dogfood is successful only if a coordinator can follow this loop without internal IDs:
1. Create/use a Run.
2. Start one local worker and one connected-server worker.
3. Wait for both starts to settle.
4. Call `worker-read --source auto` for each Dispatch.
5. Continue each cursor through at least two pages.
6. Confirm output belongs to the correct prompt and machine.
7. Trigger one fallback case.
8. Complete both workers and confirm reads never altered lifecycle state.
Record:
- command and response;
- chosen source and fallback reason;
- worker server/platform;
- provider and session match;
- cursor behavior;
- path-leak check;
- restart/disconnect outcome; and
- any agent confusion using only CLI help and the orchestration skill.
## Acceptance criteria
Implementation is complete only when:
- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback.
- No test or dogfood scenario reads a sibling or previous provider session.
- A cursor never switches source or provider session silently.
- Mac-to-Windows and Windows-to-Mac physical reads pass.
- Runtime restart and disconnect behavior are safe and understandable.
- Unsupported agents and mixed versions retain useful terminal output.
- Transcript paths never leave the server that owns them.
- Existing terminal-read clients remain compatible.
- The common agent path remains one command with no server/session/path inputs.
- No UI, scheduling, retry, integration tracking, or generalized provider framework is added.
## Explicit non-goals
- No dashboard or sidebar changes.
- No coordinator chat changes.
- No automatic worker placement, retry, replacement, or recovery.
- No commit, test, branch, merge, or integration tracking.
- No provider-session locking or resume orchestration.
- No live transcript subscription in the orchestration API.
- No universal transcript/event ontology.
- No cross-server filesystem access from the Run home.
- No replicated Run database or automatic Run-home failover.
- No generalized access-control or capability framework.
## Main risks and mitigations
| Risk | Mitigation |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. |
| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. |
| A session changes between pages | Pin source identity in the cursor and return `source_changed`. |
| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. |
| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. |
| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. |
| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. |
| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. |
| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. |
## Decision
Implement structured worker output as a narrow extension of `worker-read`.
The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped
provider sessions. The work is to make that existing association available to the worker-owning
runtime, read the transcript locally through proven readers, pin pagination to that source, and
federate only the bounded result.
+3 -2
View File
@@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** All other groups are full, now we're on group 5.
- **WeChat:** If group 5 is full, you can join group 6.
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr.jpg" alt="WeChat group 5 QR code for the Orca community" width="160" style="margin-right: 16px;" />
<img src="docs/assets/wechat-qr-group6.jpg" alt="WeChat group 6 QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
+6 -1
View File
@@ -83,12 +83,17 @@ module.exports = {
'!skill-guides{,/**/*}',
'!skill-stubs{,/**/*}',
'!tests{,/**/*}',
// Why: examples/ is plugin authoring documentation with no runtime consumer —
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
// carries hostile-panel, the adversarial fixture the containment tests point at,
// which must never reach a user's install.
'!examples{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
@@ -1,6 +1,6 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": ["import", "jsx-a11y", "promise", "react", "react-hooks", "typescript", "vitest"],
"plugins": ["import", "jsx-a11y", "react-hooks", "vitest"],
"categories": {
"correctness": "off",
"suspicious": "off",
@@ -10,34 +10,13 @@
"restriction": "off",
"nursery": "off"
},
"options": {
"reportUnusedDisableDirectives": "warn"
},
"jsPlugins": [
{
"name": "app-store-performance",
"specifier": "./oxlint-plugins/app-store-performance.mjs"
}
],
"rules": {
"app-store-performance/require-selector": "warn",
"app-store-performance/no-identity-selector": "warn",
"app-store-performance/no-fresh-selector-result": "warn",
"import/export": "warn",
"import/named": "warn",
"import/namespace": "warn",
"import/no-cycle": ["warn", { "maxDepth": 3 }],
"import/no-duplicates": "warn",
"import/no-self-import": "warn",
"max-params": ["warn", 5],
"no-fallthrough": "warn",
"no-loop-func": "warn",
"no-promise-executor-return": "warn",
"no-unmodified-loop-condition": "warn",
"preserve-caught-error": "warn",
"promise/no-multiple-resolved": "warn",
"react/no-unstable-nested-components": "warn",
"react/react-compiler": "warn"
"import/no-self-import": "warn"
},
"overrides": [
{
@@ -55,9 +34,7 @@
"rules": {
"vitest/no-conditional-tests": "warn",
"vitest/no-focused-tests": "warn",
"vitest/no-identical-title": "warn",
"vitest/valid-expect": "warn",
"vitest/valid-title": "warn"
"vitest/no-identical-title": "warn"
}
},
{
+13 -4
View File
@@ -12,11 +12,20 @@
},
"rules": {
"typescript/await-thenable": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-misused-promises": "warn",
"typescript/only-throw-error": "warn",
"typescript/restrict-plus-operands": "warn",
"typescript/restrict-template-expressions": "warn"
"typescript/restrict-template-expressions": "warn",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
]
},
"overrides": [
{
"files": ["**/*.test.*", "**/*.spec.*"],
"rules": {
"typescript/await-thenable": "off"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
@@ -0,0 +1,266 @@
const LOOP_TYPES = new Set([
'ForStatement',
'ForInStatement',
'ForOfStatement',
'WhileStatement',
'DoWhileStatement'
])
const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '??=', '||=', '&&='])
const EXPRESSION_WRAPPERS = new Set([
'ChainExpression',
'TSAsExpression',
'TSNonNullExpression',
'TSSatisfiesExpression',
'TypeCastExpression'
])
function normalizeReferenceText(text) {
return text.replaceAll(/\s+/g, '')
}
function sourceText(context, node) {
return context.sourceCode.getText(node)
}
function memberPropertyName(node) {
if (node?.type !== 'MemberExpression') {
return null
}
if (!node.computed && node.property.type === 'Identifier') {
return node.property.name
}
return node.property.type === 'Literal' && typeof node.property.value === 'string'
? node.property.value
: null
}
function rootReferenceText(context, node) {
if (node?.type === 'Identifier') {
return node.name
}
if (node?.type === 'MemberExpression') {
return node.object.type === 'ThisExpression'
? normalizeReferenceText(sourceText(context, node))
: rootReferenceText(context, node.object)
}
if (node?.type === 'CallExpression') {
return rootReferenceText(context, node.callee)
}
if (EXPRESSION_WRAPPERS.has(node?.type)) {
return rootReferenceText(context, node.expression)
}
return null
}
function isBufferConcatCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Buffer' &&
memberPropertyName(node.callee) === 'concat' &&
node.arguments[0]?.type === 'ArrayExpression'
)
}
function enclosingLoop(node) {
for (let current = node.parent; current; current = current.parent) {
if (LOOP_TYPES.has(current.type)) {
return current
}
}
return null
}
function nodeStart(node) {
return node.start ?? node.range?.[0] ?? 0
}
function nodeEnd(node) {
return node.end ?? node.range?.[1] ?? 0
}
function isDeclaredInsideLoop(declarationStart, loop) {
if (declarationStart < nodeStart(loop) || declarationStart >= nodeEnd(loop)) {
return false
}
if (loop.type !== 'ForStatement' || !loop.init) {
return true
}
return declarationStart < nodeStart(loop.init) || declarationStart >= nodeEnd(loop.init)
}
function collectBindingNames(pattern, names) {
if (!pattern) {
return
}
if (pattern.type === 'Identifier') {
names.push(pattern.name)
} else if (pattern.type === 'RestElement') {
collectBindingNames(pattern.argument, names)
} else if (pattern.type === 'AssignmentPattern') {
collectBindingNames(pattern.left, names)
} else if (pattern.type === 'ObjectPattern') {
for (const property of pattern.properties) {
collectBindingNames(
property.type === 'RestElement' ? property.argument : property.value,
names
)
}
} else if (pattern.type === 'ArrayPattern') {
for (const element of pattern.elements) {
collectBindingNames(element, names)
}
}
}
function visitChildren(node, visit) {
for (const [key, child] of Object.entries(node)) {
if (['parent', 'loc', 'range'].includes(key)) {
continue
}
if (Array.isArray(child)) {
for (const item of child) {
if (item?.type) {
visit(item)
}
}
} else if (child?.type) {
visit(child)
}
}
}
function collectAssignedRoots(context, loop) {
const assigned = new Set()
const visit = (node) => {
if (node.type === 'AssignmentExpression' && ASSIGNMENT_OPERATORS.has(node.operator)) {
const root = rootReferenceText(context, node.left)
if (root) {
assigned.add(root)
}
}
visitChildren(node, visit)
}
visit(loop.body)
return assigned
}
function assignmentTargetOf(context, call) {
let node = call
let parent = node.parent
while (
parent &&
(EXPRESSION_WRAPPERS.has(parent.type) ||
(parent.type === 'ConditionalExpression' && parent.test !== node))
) {
node = parent
parent = parent.parent
}
if (parent?.type !== 'AssignmentExpression' || parent.operator !== '=' || parent.right !== node) {
return null
}
return {
text: normalizeReferenceText(sourceText(context, parent.left)),
root: rootReferenceText(context, parent.left)
}
}
function concatOperands(context, call) {
return call.arguments[0].elements.filter(Boolean).map((element) => {
const spread = element.type === 'SpreadElement'
const expression = spread ? element.argument : element
return {
spread,
text: normalizeReferenceText(sourceText(context, expression)),
root: rootReferenceText(context, expression)
}
})
}
function isLoopCarried(root, loop, declarations) {
const starts = declarations.get(root)
return !starts || !starts.some((start) => isDeclaredInsideLoop(start, loop))
}
function quadraticAccumulator(context, call, loop, declarations, assignedRoots) {
const operands = concatOperands(context, call)
const target = assignmentTargetOf(context, call)
const selfOperand = target
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
: null
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
return target.text
}
for (const operand of operands) {
if (
!operand.spread &&
operand.root &&
assignedRoots.has(operand.root) &&
isLoopCarried(operand.root, loop, declarations)
) {
return operand.root
}
}
return null
}
function createRule(context) {
const declarations = new Map()
const assignedRootsByLoop = new WeakMap()
const recordBindings = (pattern, owner) => {
const names = []
collectBindingNames(pattern, names)
for (const name of names) {
const starts = declarations.get(name) ?? []
starts.push(nodeStart(owner))
declarations.set(name, starts)
}
}
const recordParameters = (node) => {
for (const parameter of node.params) {
recordBindings(parameter, parameter)
}
}
return {
VariableDeclarator(node) {
recordBindings(node.id, node)
},
FunctionDeclaration: recordParameters,
FunctionExpression: recordParameters,
ArrowFunctionExpression: recordParameters,
CatchClause(node) {
recordBindings(node.param, node.param)
},
CallExpression(node) {
if (!isBufferConcatCall(node)) {
return
}
const loop = enclosingLoop(node)
if (!loop) {
return
}
let assignedRoots = assignedRootsByLoop.get(loop)
if (!assignedRoots) {
assignedRoots = collectAssignedRoots(context, loop)
assignedRootsByLoop.set(loop, assignedRoots)
}
const accumulator = quadraticAccumulator(context, node, loop, declarations, assignedRoots)
if (accumulator) {
context.report({
node,
message: `Buffer.concat rebuilds loop-carried ${accumulator}; collect chunks and concatenate once after the loop.`
})
}
}
}
}
export default {
meta: { name: 'quadratic-buffer-concat' },
rules: {
'no-loop-carried-concat': { create: createRule }
}
}
@@ -0,0 +1,292 @@
const STYLED_SCROLLBAR_CLASSES = new Set([
'scrollbar-sleek',
'scrollbar-editor',
'worktree-sidebar-scrollbar'
])
const VERTICAL_SCROLL_CLASSES = new Set([
'overflow-auto',
'overflow-scroll',
'overflow-y-auto',
'overflow-y-scroll'
])
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
function withoutImportantModifier(className) {
const withoutPrefix = className.startsWith('!') ? className.slice(1) : className
return withoutPrefix.endsWith('!') ? withoutPrefix.slice(0, -1) : withoutPrefix
}
export function plainClassName(token) {
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
const parts = []
let bracketDepth = 0
let currentPart = ''
for (const char of normalizedToken) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
parts.push(currentPart)
currentPart = ''
} else {
currentPart += char
}
}
parts.push(currentPart)
return withoutImportantModifier(parts.at(-1) ?? '')
}
function classTokenParts(token) {
const variants = []
let bracketDepth = 0
let currentPart = ''
for (const char of token.startsWith('!') ? token.slice(1) : token) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
variants.push(currentPart)
currentPart = ''
} else {
currentPart += char
}
}
return { className: withoutImportantModifier(currentPart), variants: variants.filter(Boolean) }
}
function classTokens(text) {
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
}
function sameVariants(left, right) {
return left.length === right.length && left.every((variant, index) => variant === right[index])
}
function literalHasScrollbarForVertical(text, verticalToken) {
return classTokens(text).some(
(candidate) =>
STYLED_SCROLLBAR_CLASSES.has(candidate.className) &&
(candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants))
)
}
function uncoveredVerticalClass(text) {
return classTokens(text).find(
(token) =>
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
)
}
function stringLiteralTexts(node) {
if (node?.type === 'Literal' && typeof node.value === 'string') {
return [node.value]
}
if (node?.type !== 'TemplateLiteral') {
return []
}
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
}
function visitChildren(node, visit) {
for (const [key, child] of Object.entries(node)) {
if (['parent', 'loc', 'range'].includes(key)) {
continue
}
if (Array.isArray(child)) {
for (const item of child) {
if (item?.type) {
visit(item)
}
}
} else if (child?.type) {
visit(child)
}
}
}
function collectClassLiteralReports(node) {
const reports = []
const visit = (current) => {
for (const text of stringLiteralTexts(current)) {
const uncovered = uncoveredVerticalClass(text)
if (uncovered) {
reports.push({ node: current, detail: uncovered.className })
}
}
visitChildren(current, visit)
}
visit(node)
return reports
}
function expressionHasStyledScrollbarLiteral(node) {
let found = false
const visit = (current) => {
if (found || current.type === 'ConditionalExpression' || current.type === 'LogicalExpression') {
return
}
found = stringLiteralTexts(current).some((text) =>
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
)
if (!found) {
visitChildren(current, visit)
}
}
visit(node)
return found
}
function propertyName(node) {
if (node?.type !== 'Property') {
return null
}
if (!node.computed && node.key.type === 'Identifier') {
return node.key.name
}
return node.key.type === 'Literal' && typeof node.key.value === 'string' ? node.key.value : null
}
function styleValueIsVerticalScroll(name, value) {
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (parts.length === 0) {
return false
}
if (name === 'overflowY' || name === 'overflow-y') {
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
}
if (name !== 'overflow') {
return false
}
return VERTICAL_SCROLL_STYLE_VALUES.has(parts.length > 1 ? parts[1] : parts[0])
}
function collectStyleReports(node) {
const reports = []
const visit = (current) => {
if (current.type === 'Property') {
const name = propertyName(current)
for (const value of name ? stringLiteralTexts(current.value) : []) {
if (styleValueIsVerticalScroll(name, value)) {
reports.push({ node: current, detail: 'inline vertical scroll' })
}
}
visit(current.value)
return
}
visitChildren(current, visit)
}
visit(node)
return reports
}
function unwrapExpression(node) {
if (
['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression'].includes(
node?.type
)
) {
return unwrapExpression(node.expression)
}
return node
}
function spreadPropExpressions(expression, propName) {
const node = unwrapExpression(expression)
if (!node) {
return []
}
if (node.type === 'ConditionalExpression') {
return [
...spreadPropExpressions(node.consequent, propName),
...spreadPropExpressions(node.alternate, propName)
]
}
if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') {
return [
...spreadPropExpressions(node.left, propName),
...spreadPropExpressions(node.right, propName)
]
}
if (node.type !== 'ObjectExpression') {
return []
}
return node.properties.flatMap((property) => {
if (property.type === 'SpreadElement') {
return spreadPropExpressions(property.argument, propName)
}
return propertyName(property) === propName ? [property.value] : []
})
}
function jsxAttributeExpression(attribute) {
if (attribute.value?.type === 'Literal') {
return attribute.value
}
return attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : null
}
function jsxElementReports(node) {
let classExpression = null
const styleExpressions = []
for (const attribute of node.attributes) {
if (attribute.type === 'JSXSpreadAttribute') {
const spreadClassExpression = spreadPropExpressions(attribute.argument, 'className').at(-1)
if (spreadClassExpression) {
classExpression = spreadClassExpression
}
styleExpressions.push(...spreadPropExpressions(attribute.argument, 'style'))
} else if (attribute.name?.name === 'className') {
classExpression = jsxAttributeExpression(attribute)
} else if (attribute.name?.name === 'style') {
const expression = jsxAttributeExpression(attribute)
if (expression) {
styleExpressions.push(expression)
}
}
}
const reports = classExpression ? collectClassLiteralReports(classExpression) : []
if (!classExpression || !expressionHasStyledScrollbarLiteral(classExpression)) {
for (const expression of styleExpressions) {
reports.push(...collectStyleReports(expression))
}
}
return reports
}
function bindContext(createVisitors) {
return (context) => {
const visitors = createVisitors()
for (const [nodeType, visit] of Object.entries(visitors)) {
visitors[nodeType] = visit.bind(context)
}
return visitors
}
}
export default {
meta: { name: 'renderer-scrollbar-style' },
rules: {
'require-styled-vertical-scrollbar': {
create: bindContext(() => ({
JSXOpeningElement(node) {
for (const report of jsxElementReports(node)) {
this.report({
node: report.node,
message: `Vertical scroll container (${report.detail}) must use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.`
})
}
}
}))
}
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
]
}
}
+540 -25
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-26",
"updatedAt": "2026-07-28",
"policy": {
"maturityLevels": [
"experimental",
@@ -253,7 +253,11 @@
"surfaces": [
"terminal input availability",
"worktree discovery",
"Source Control status refresh"
"Source Control status refresh",
"direct SSH detected-worktree scheduling",
"direct SSH reconnect telemetry",
"direct SSH host catalog authority",
"direct SSH generation-scope rollover"
],
"platforms": [
"macos",
@@ -271,14 +275,17 @@
"local",
"ssh"
],
"coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, and Source Control active-visible repo filtering. Linux/Windows are covered at the shared poller layer by forcing the non-darwin path; live platform runs remain gaps.",
"coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A macOS Electron client completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, paired-client, and WSL runs remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7086"
],
"invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout.",
"oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters.",
"invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control; external head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.",
"oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents.test.ts --reporter=dot",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/ipc/worktree-base-directory-event-filter.test.ts",
@@ -287,7 +294,15 @@
"src/main/ipc/worktree-head-identity-reader.test.ts",
"src/renderer/src/hooks/worktree-head-identity-apply.test.ts",
"src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts",
"src/renderer/src/hooks/useIpcEvents.test.ts"
"src/renderer/src/hooks/useIpcEvents.test.ts",
"src/main/ipc/repos-remote.test.ts",
"src/main/ssh/ssh-connection-generation.test.ts",
"src/main/ssh/ssh-provider-authority.test.ts",
"src/shared/direct-ssh-reconnect-telemetry-schema.test.ts",
"src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts",
"src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts",
"src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts",
"tests/e2e/ssh-docker-relay-perf.spec.ts"
],
"assertionRefs": [
{
@@ -343,7 +358,66 @@
{
"file": "src/renderer/src/hooks/useIpcEvents.test.ts",
"assertions": [
"renderer preload API fixtures include the status-metadata and head-identity subscription contracts"
"renderer preload API fixtures include the status-metadata and head-identity subscription contracts",
"direct SSH coordinator telemetry is wired through the fail-soft product adapter"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
"a host-qualified catalog rejects contradictory executionHostId and connectionId provenance without returning rows",
"local, sibling SSH, and runtime rows remain excluded from the exact direct SSH catalog"
]
},
{
"file": "src/main/ssh/ssh-connection-generation.test.ts",
"assertions": [
"one exhausted target rolls the process generation scope and invalidates every sibling target token",
"old-scope mutation expectations fail while the new-scope authority continues rotating"
]
},
{
"file": "src/main/ssh/ssh-provider-authority.test.ts",
"assertions": [
"generation-scope rollover invalidates every target authority before abort callbacks run",
"every registered old-scope provider request aborts exactly once"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts",
"assertions": [
"coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin",
"the first timeout remains retrying and reports queue wait separately from provider execution",
"cancel debt admits at most two replacements and terminally distinguishes budget exhaustion"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts",
"assertions": [
"lineage and token creation remain blocked until a timed-out repo retry settles",
"exact overlapping preparation emits one aggregate with a join count",
"telemetry callback failure cannot affect reconnect completion"
]
},
{
"file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts",
"assertions": [
"timeout, rejection, cancellation, and stale outcomes have independent fields",
"target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected"
]
},
{
"file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts",
"assertions": [
"one coordinator aggregate maps to one typed product event with queue and provider percentiles",
"adapter failure is swallowed before it can reach recovery"
]
},
{
"file": "tests/e2e/ssh-docker-relay-perf.spec.ts",
"assertions": [
"repo and worktree hydration use the exact direct SSH host and complete provider authority",
"terminal input remains live after disconnect/reconnect and writes a proof file visible inside the Linux target"
]
}
],
@@ -356,23 +430,50 @@
"result": "passed",
"durationSeconds": 3.12,
"summary": "7 files and 130 tests passed locally, adding head-identity emit-on-change without structural fanout, reflog status triggers, config.worktree structural classification, the in-place index backstop, and the spawn-free head reader with symref traversal rejection and hex-object-id output validation."
},
{
"date": "2026-07-27",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 1.61,
"summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation."
},
{
"date": "2026-07-27",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 3.45,
"summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests."
},
{
"date": "2026-07-27",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 66,
"summary": "Four Electron Docker SSH tests passed: two typing/performance paths, one concurrent file/Git load path, and exact-authority disconnect/reconnect with a container-visible remote proof file."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "focused main/preload/renderer unit and polling tests"
"p95Seconds": 15,
"scope": "focused main/preload/renderer polling and direct SSH scheduler/telemetry tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New experimental deterministic gate with one local macOS run; needs CI soak before promotion."
"evidence": "Three deterministic local macOS runs cover the original watcher lane, main catalog/authority lane, and direct SSH scheduler/telemetry lane; CI soak is still unavailable."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. Saved CI red/green artifacts are still needed before blocking promotion."
"evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping, compares HEAD/gitdir/locked signatures every tick, and gates linked index inspection behind the entry-directory signature. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; a locked create/delete positive control still caused structural refreshes."
"evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed."
},
"promotionCriteria": [
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
@@ -383,7 +484,10 @@
"The live Electron diagnostic and screenshot evidence must remain attached to the motivating PR for durable review.",
"Linux and Windows are forced through the shared non-darwin poller in unit tests but are not live-tested here.",
"Git loose ref watching remains outside this incident fix by design.",
"SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation."
"SSH watches classify head-move triggers but skip the metadata-read identity diff; remote background-worktree heads still wait on a structural event or activation.",
"The Docker/Linux journey covers one direct SSH target; a live multi-target fanout and large-catalog benchmark remains missing.",
"Paired web clients intentionally do not run the desktop direct SSH coordinator, and paired-close non-interference lacks a new live run.",
"WSL direct SSH fanout remains an explicit live-test gap rather than inferred coverage."
],
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge."
},
@@ -3567,6 +3671,10 @@
"layer": "provider-contract",
"surfaces": [
"SSH deferred restore",
"direct SSH reconnect finalization",
"direct SSH folder workspace reattach",
"direct SSH split-pane retry ownership",
"same-authority terminal correction",
"remote-runtime mirror polling",
"remote-runtime network recovery",
"terminal create idempotency",
@@ -3591,7 +3699,7 @@
"ssh",
"remote-runtime"
],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Capability-gated create retries adopt a provider-owned PTY by stable terminal identity after an unknown outcome or runtime-process restart, stop retrying after one minute without a fatal error, and remain manually retryable without accepting stale create completions. A macOS Electron journey covers live SSH restore; a Windows remote-runtime smoke covers reachability and PTY round-trip. A live partition journey using patched Mac and Windows builds remains a gap.",
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
@@ -3599,14 +3707,18 @@
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, and manual reconnect. They assert one unsubscribe per epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, one-minute create-retry cutoff, old-runtime no-retry behavior, authenticated client/worktree isolation, cross-process PTY adoption without rerunning startup, unavailable or legacy-incomplete inventory fail-closed behavior, and bounded in-flight coordination. Existing count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind.",
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
@@ -3615,6 +3727,7 @@
"src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts",
"src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx",
@@ -3629,8 +3742,32 @@
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts",
"src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts",
"src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts",
"src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts",
"src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts",
"src/renderer/src/store/slices/terminals-hydration.test.ts",
"src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts",
"src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts",
"src/renderer/src/hooks/direct-ssh-host-hydration.test.ts",
"src/renderer/src/hooks/direct-ssh-state-routing.test.ts",
"src/renderer/src/hooks/remote-workspace-target-sync.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"src/main/ipc/repos-remote.test.ts",
"src/main/ipc/ssh.test.ts",
"src/main/ipc/worktrees.test.ts",
"src/main/runtime/public-ssh-state.test.ts",
"src/main/ssh/ssh-connection-manager.test.ts",
"src/main/ssh/ssh-connection.test.ts",
"src/main/ssh/ssh-provider-authority.test.ts",
"src/preload/ssh-authority-forwarding.test.ts",
"src/renderer/src/runtime/runtime-client-events.test.ts",
"src/renderer/src/runtime/runtime-environment-ssh-state.test.ts",
"src/shared/ssh-retained-payload-admission.test.ts",
"src/shared/ssh-types.test.ts",
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
"tests/e2e/ssh-cold-activation-restore.spec.ts"
"tests/e2e/ssh-cold-activation-restore.spec.ts",
"tests/e2e/ssh-docker-relay-perf.spec.ts"
],
"assertionRefs": [
{
@@ -3720,6 +3857,189 @@
"snapshot-first replacement still migrates stale PTY-indexed state"
]
},
{
"file": "src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts",
"assertions": [
"one atomic patch clears exact-target live PTY indexes while preserving relay identity and null-PTY activation",
"another SSH target, local, WSL, floating, and runtime-owned terminal state remains unchanged"
]
},
{
"file": "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts",
"assertions": [
"Git and folder workspaces resolve only from consistent exact-target provenance",
"ambiguous, contradictory, mixed, and runtime-owned folders fail closed"
]
},
{
"file": "src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts",
"assertions": [
"one authority chain permits at most two automatic attempts even when both timeouts exceed the rolling thirty-second window",
"rejected stale-authority, stale-attempt, or pre-commit success acknowledgements mutate none of the tab, PTY-index, pending, history, or live-binding maps",
"both split-pane siblings bind under one exact attempt while the first PTY remains the tab fallback",
"a sibling failure starts one second tab-wide attempt and stale first-attempt callbacks preserve it",
"an exhausted attempt retains sibling continuation authority and promotes a surviving primary PTY without attempt three",
"primary exit before sibling commit preserves the exact continuation lease and accepts the late sibling"
]
},
{
"file": "src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts",
"assertions": [
"primary and non-primary split detach preserve exact live authority and retry history on both resulting tabs",
"detaching the only bound split while its sibling is still spawning preserves the source continuation lease until that sibling binds",
"detaching during a null-PTY continuation gap projects the exact lease to both pending tabs",
"a pending-only all-null detach preserves the exact lease on both tabs before either leaf binds",
"same-authority invalidation and correction leave both detached live PTYs unchanged"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"assertions": [
"a detached null-PTY leaf remains marked for pending activation before ownership transfer"
]
},
{
"file": "src/main/ipc/repos-remote.test.ts",
"assertions": [
"host-qualified repo catalogs require one consistent execution host and complete current SSH authority",
"contradictory, partial, mismatched, stale, and runtime-owned catalog requests fail closed"
]
},
{
"file": "src/main/ipc/ssh.test.ts",
"assertions": [
"concurrent same-authority connects share one provider attempt",
"authority rotation starts stale transport cancellation before teardown, concurrent fresh callers share one replacement, and stale completion cannot clobber the fresh session",
"same-turn disconnect and forward-teardown failures across removal, reset, and terminate keep replacement connects and metadata mutation behind complete target cleanup"
]
},
{
"file": "src/main/ssh/ssh-connection-manager.test.ts",
"assertions": [
"disconnect invalidates a pending transport attempt immediately so late rejection or resolution cannot remove the replacement"
]
},
{
"file": "src/main/ssh/ssh-connection.test.ts",
"assertions": [
"late ssh2 ready and startup error events after disconnect cannot resurrect or overwrite disconnected state"
]
},
{
"file": "src/main/ipc/worktrees.test.ts",
"assertions": [
"host-qualified worktree reads reject malformed or contradictory repo executionHostId/connectionId provenance before provider access and after provider awaits without durable mutations",
"host-qualified lineage excludes other SSH and runtime owners and rejects ambiguous or contradictory provenance",
"one lineage request snapshots repo, folder, and group catalogs once and memoizes repeated owner resolution"
]
},
{
"file": "src/main/runtime/public-ssh-state.test.ts",
"assertions": [
"public SSH state preserves the complete provider epoch and connection generation pair"
]
},
{
"file": "src/main/ssh/ssh-provider-authority.test.ts",
"assertions": [
"provider epoch and connection generation rotate as one exact authority pair",
"provider resolution rejects stale or incomplete authority",
"unknown-target currency probes reject without allocating provider authority state"
]
},
{
"file": "src/preload/ssh-authority-forwarding.test.ts",
"assertions": [
"full authority crosses Electron IPC without loss",
"partial authority becomes unknown for bounded reconciliation and malformed full authority is dropped",
"variable-form host-qualified worktree requests retain fail-closed outcomes in their return type"
]
},
{
"file": "src/shared/ssh-retained-payload-admission.test.ts",
"assertions": [
"retained connection states reject partial or malformed authority",
"only partial compatibility authority can normalize to unknown for bounded direct-SSH reconciliation",
"shared direct SSH authority admission requires bounded identifiers and a non-negative safe generation"
]
},
{
"file": "src/renderer/src/runtime/runtime-client-events.test.ts",
"assertions": [
"retained runtime snapshots and live events preserve the full pair",
"partial runtime authority is rejected before it reaches environment state"
]
},
{
"file": "src/renderer/src/runtime/runtime-environment-ssh-state.test.ts",
"assertions": [
"runtime-owned SSH state remains isolated by environment and rejects partial retained authority",
"in-flight hydration cannot resurrect disconnected or removed runtime environments"
]
},
{
"file": "src/shared/ssh-types.test.ts",
"assertions": [
"SSH connection state carries the provider epoch and connection generation authority pair"
]
},
{
"file": "src/renderer/src/store/slices/terminals-hydration.test.ts",
"assertions": [
"target-scoped hydration and reconnect preserve sibling SSH and runtime tabs, PTY indexes, runtime ownership, and active selection",
"authoritative target-tab deletion prunes only that tab's retry, live-binding, and retry-history ledgers",
"a snapshot PTY from another SSH host is rejected from the target scope"
]
},
{
"file": "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts",
"assertions": [
"a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts",
"assertions": [
"terminal invalidation and retry run synchronously before provider preparation",
"hydrated terminal finalization and same-authority correction are current-authority fenced",
"rapid authority rotation keeps immediate terminal checks while damping full preparation"
]
},
{
"file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts",
"assertions": [
"exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows"
]
},
{
"file": "src/renderer/src/hooks/remote-workspace-target-sync.test.ts",
"assertions": [
"snapshot hydration preserves newer local recovery and keeps imported PTY ids retryable until exact-attempt transport acknowledgement",
"stale operation tokens cannot apply an older snapshot over current authority",
"target snapshot projection and persisted-terminal reconnect are host-qualified and preserve sibling SSH, local, WSL, and runtime state"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"StrictMode remounts join only the same direct SSH retry attempt",
"authority rotation starts a new spawn and rejects then retires a late obsolete-authority fresh PTY",
"late stale rebind and reattach completions, including lease replacement during asynchronous SSH preparation, callback errors, rejected promises, session-expired, empty, and launch-metadata outcomes, cannot clear current state, start replacement recovery, publish errors, or publish metadata",
"both concurrent split-pane spawns commit through the same exact retry attempt",
"both concurrent split-pane reattaches commit through the same exact retry attempt",
"a sibling mounted after first success captures the retained live lease",
"authority rotation rejects and retires a delayed sibling spawned from a retained live lease",
"intentional pane disposal cancels retry settlement while a same-attempt StrictMode remount retains one timeout"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"assertions": [
"admission rejection precedes buffered final-frame and exit publication",
"abandoning an obsolete reattach drops its data, replay, write-unavailable, and exit handlers without killing the durable PTY",
"a rejected or destroyed fresh session fallback settles retirement before it can publish handlers, shutdown refusal is reported as unknown, and reattach remains non-destructive"
]
},
{
"file": "tests/e2e/ssh-cold-activation-restore.spec.ts",
"assertions": [
@@ -3727,6 +4047,13 @@
"all six SSH managers mount eagerly and none is parked",
"restored terminal input reaches a proof file on the Linux SSH host"
]
},
{
"file": "tests/e2e/ssh-docker-relay-perf.spec.ts",
"assertions": [
"repo and worktree setup fails closed unless the exact direct SSH host and complete authority are returned",
"a reconnected terminal accepts input and writes a proof file visible inside the Linux SSH target"
]
}
],
"evidenceRuns": [
@@ -3756,23 +4083,68 @@
"result": "passed",
"durationSeconds": 5,
"summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-retry.test.ts src/renderer/src/store/slices/direct-ssh-pane-detach-ledger.test.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/hooks/remote-workspace-target-sync.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 15.8,
"summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"result": "passed",
"durationSeconds": 1.48,
"summary": "All 90 transport tests passed, including pre-publication admission rejection, handler-complete non-destructive detach for obsolete SSH reattach transports, settled retirement of rejected or destroyed fresh fallbacks, and reported shutdown refusal."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts src/shared/ssh-types.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 2.55,
"summary": "Twelve main, preload, runtime, and shared authority files and 513 tests passed, including fail-closed pre/post-await repo ownership provenance, production retained-payload admission, fenced stale-transport replacement, failure-safe target lifecycle barriers, and real ssh2 late-ready/error rejection."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-docker-relay-perf.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 51.4,
"summary": "Four Electron Docker SSH tests passed on the final implementation, including exact-authority repo/worktree hydration, two concurrent immutable file streams under Git churn, live terminal input before and after disconnect/reconnect, and an independent container-visible remote proof file."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 12.5,
"summary": "One Electron journey passed on the final implementation after exact-authority hydration; six restored SSH terminal managers remounted after renderer reload and remote input reached the Linux target."
}
],
"runtimeBudget": {
"p95Seconds": 45,
"scope": "provider contract plus optional SSH soak"
"p95Seconds": 150,
"scope": "all configured provider-contract, build, Electron, Docker SSH reconnect, and six-terminal cold-restore commands"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused renderer contracts and one clean Docker/Linux SSH Electron run pass locally; the live journey needs CI soak history before promotion."
"evidence": "Focused renderer contracts, one current 627-test direct SSH run, and current Docker/Linux reconnect and six-terminal cold-restore journeys pass locally. One pressure run entered reconnect while waiting for its seventh marker; later runs delivered all markers but exposed an independent disappearing-second-file fixture race. The corrected two-reader single-file load passed the complete 4/4 suite while retaining concurrent stream pressure. Live multi-target fanout still needs CI soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. The bounded-recovery tests additionally failed before the policy change because PTY and terminal-create recovery remained active after one minute and a retry reused the stale epoch. Final review tests failed before lifecycle fencing because create cutoff emitted a fatal error with no manual path and a delayed create completion replaced a newer cross-runtime attachment. The fixed tests pass with resumed snapshot/output/input, one-minute cutoffs, new manual epochs, capability re-probing, stale-create rejection, and cross-process provider PTY adoption without rerunning startup. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, a patched live remote-runtime partition journey, and saved intentional-break artifacts before promotion."
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action; regular PTYs and initial creates do not poll inventory. Each unknown-outcome create attempt performs one bounded provider inventory scan, coordinated per authenticated client/worktree mutation, and the renderer stops issuing attempts after one minute. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch and cutoff tests prove request counts stay fixed for five additional minutes. Client and server liveness each use one interval per socket/transport. At most 4,096 create promises are retained only while in flight, and capacity rejection happens before spawning. Common terminal input/output paths add only constant-time state checks; recovery UI updates only on deduplicated phase transitions."
"evidence": "Direct SSH terminal invalidation and retry each use one exact-target store publication and execute before provider discovery; another target's five occupied provider slots cannot delay terminal finalization. Each split-pane completion or delayed mount adds constant-time pending/live lease lookups and no provider listing, polling, subprocess, cross-tab scan, or new fanout; two mounted leaves still perform exactly their two existing provider operations. The scheduler caps locally unsettled detected-worktree work at five with a two-call late-work allowance. Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch. Common terminal input/output paths add only constant-time state checks. No live large-terminal-map direct SSH timing is claimed."
},
"promotionCriteria": [
"Use deterministic fake providers for failure and unknown-liveness cases.",
@@ -3782,7 +4154,9 @@
"knownGaps": [
"Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.",
"The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.",
"WSL restore remains inferred rather than directly covered.",
"Current Docker/Linux journeys prove one target's reconnect and cold-restore paths; live multi-target fanout, folder-workspace reconnect, and large-terminal-map timing remain untested.",
"No paired-client close/non-interference journey was run for direct SSH reconnect; paired web clients intentionally remain outside coordinator ownership.",
"WSL restore and direct SSH/WSL isolation remain inferred rather than directly covered.",
"Linux and Windows desktop-client partition journeys using patched builds are not yet collected; the Windows smoke proves current reachability and PTY round-trip only.",
"Terminal-create recovery depends on providers authoritatively listing live terminal handles and worktree ownership; older runtimes do not advertise the capability and are never retried after an unknown outcome."
],
@@ -4759,6 +5133,147 @@
],
"demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner."
},
{
"id": "orchestration.worker-terminal-delivery",
"title": "Started workers are visible without stealing focus and retain mailbox identity",
"maturity": "experimental",
"protection": "partial",
"owner": "orchestration",
"layer": "cli-runtime-renderer-contract",
"surfaces": [
"Run and Dispatch mailboxes",
"worker-start",
"terminal creation",
"terminal tab materialization",
"workspace re-entry"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"daemon",
"ssh"
],
"coverageNotes": "Deterministic units cover local worker presentation, reveal-failure warnings, stable-pane Run/Dispatch routing, and the SSH in-process CLI fallback. An isolated macOS Electron journey launches a fake Codex worker through the real RPC path, tolerates spawn-time handle reminting, asserts the inactive tab in the DOM before navigation, checks Run delivery by pane identity, and proves workspace re-entry keeps one worker tab by both original tab ID and visible title. SSH, WSL, remote-runtime, Linux, and Windows remain live-test gaps; federated workers retain explicit background presentation.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"
],
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through a production Electron runtime with a deterministic Codex fixture. Require the worker tab to be visible in the DOM with data-active=false while the coordinator tab stays active, send ACK to the Run, read it with a deliberately stale coordinator handle plus its stable pane key, switch workspaces away and back, and require exactly one worker tab by original ID and visible title. Unit tests separately assert local worker-start omits background presentation, reveal failures return an actionable warning without discarding the worker, reminted coordinators and workers retain mailbox routing, live handles outrank mismatched pane metadata, and explicit legacy terminal checks do not inherit the caller pane key locally or through the SSH fallback.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1"
],
"testFiles": [
"src/cli/handlers/orchestration.test.ts",
"src/cli/handlers/orchestration-check-identity.test.ts",
"src/cli/handlers/orchestration-worker-cli.test.ts",
"src/main/runtime/rpc/methods/orchestration.test.ts",
"src/main/ssh/ssh-remote-orca-cli.test.ts",
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts"
],
"assertionRefs": [
{
"file": "src/cli/handlers/orchestration-check-identity.test.ts",
"assertions": [
"implicit check carries the caller pane key with a potentially stale environment handle",
"explicit legacy terminal inspection does not inherit the caller pane key"
]
},
{
"file": "src/cli/handlers/orchestration-worker-cli.test.ts",
"assertions": [
"worker-start prints an explicit warning when its live worker remains background-only"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration.test.ts",
"assertions": [
"same-workspace worker creation uses visible inactive presentation",
"worker-start preserves and reports renderer reveal failures",
"Run delivery resolves through a stable coordinator pane after handle remint",
"Dispatch delivery resolves through a stable worker pane after handle remint",
"a live handle cannot be retargeted by mismatched pane metadata"
]
},
{
"file": "src/main/ssh/ssh-remote-orca-cli.test.ts",
"assertions": [
"implicit SSH fallback checks retain stable pane identity",
"explicit legacy SSH inspection does not inherit the caller pane key"
]
},
{
"file": "tests/e2e/orchestration-worker-terminal-visibility.spec.ts",
"assertions": [
"worker-start exposes one inactive worker tab before workspace navigation",
"the coordinator tab remains active",
"ACK delivery reaches a stable coordinator pane through a stale handle",
"workspace re-entry does not duplicate the worker tab under the same or a new tab ID"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"result": "passed",
"durationSeconds": 5.27,
"summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity."
},
{
"date": "2026-07-28",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
"result": "passed",
"durationSeconds": 11.5,
"summary": "The isolated Electron journey passed with a real Run and Task, deterministic Codex PTY, immediate inactive DOM tab, ACK delivery through a stale coordinator handle, and exactly one worker tab by original ID and management title after workspace re-entry."
}
],
"runtimeBudget": {
"p95Seconds": 45,
"scope": "focused CLI/runtime units plus one isolated Electron worker-start journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The deterministic units and isolated Electron journey pass locally; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The focused presentation and stale-handle tests failed against the pre-fix implementation, and the live incident plus Electron topology showed the worker PTY existed without an immediate tab. Saved intentional-break and CI artifacts are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "Worker-start reuses the existing one-shot renderer reveal and adds no polling, provider listing, or output work. Check adds one optional pane-key field and reuses the existing Run scan or bounded active-Dispatch lookup; it adds no extra RPC, subprocess, timer, or renderer update. Federated and explicitly background terminals are unchanged."
},
"promotionCriteria": [
"Collect 100 consecutive passes or 14 days of stable CI history on macOS, Linux, and Windows.",
"Add live SSH and WSL exact-workspace worker-start evidence and a paired remote-runtime control.",
"Attach saved intentional-break artifacts for hidden local presentation and dropped stable-pane delivery."
],
"knownGaps": [
"No live Linux, Windows, SSH, WSL, or paired remote-runtime run is attached.",
"The Electron journey uses a deterministic fake Codex CLI rather than a real account.",
"The gate does not cover terminal output recovery after app restart; it covers identity, discoverability, focus, and mailbox routing."
],
"demotionRule": "Keep experimental or demote if the Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if workspace re-entry duplicates the tab, or if pane-stable delivery reads the wrong mailbox."
},
{
"id": "terminal-render.windows-cjk-repaint",
"title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells",
@@ -1,41 +1,20 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/app-store-performance.mjs')
const oxlintPath = path.resolve(
process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint'
)
function lintSource(source) {
const directory = mkdtempSync(path.join(tmpdir(), 'orca-app-store-lint-'))
const sourcePath = path.join(directory, 'sample.tsx')
const configPath = path.join(directory, 'oxlint.json')
writeFileSync(sourcePath, source)
writeFileSync(
configPath,
JSON.stringify({
categories: {
correctness: 'off'
},
jsPlugins: [{ name: 'app-store-performance', specifier: pluginPath }],
rules: {
'app-store-performance/require-selector': 'warn',
'app-store-performance/no-identity-selector': 'warn',
'app-store-performance/no-fresh-selector-result': 'warn'
}
})
)
const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], {
encoding: 'utf8'
return runOxlintPluginOnSource({
pluginName: 'app-store-performance',
pluginPath,
source,
rules: {
'app-store-performance/require-selector': 'warn',
'app-store-performance/no-identity-selector': 'warn',
'app-store-performance/no-fresh-selector-result': 'warn'
}
})
if (result.error) {
throw result.error
}
expect(result.status).toBe(0)
return JSON.parse(result.stdout).diagnostics
}
describe('app store performance Oxlint plugin', () => {
@@ -6,15 +6,12 @@ import { pathToFileURL } from 'node:url'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
const OXLINT_SCANS = [
export const OXLINT_SCANS = [
{
// Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root
// config would apply root rules to mobile/, whose .oxlintrc.json turns them off.
label: 'code quality',
args: [
'--config',
'config/oxlint-code-quality.json',
'--report-unused-disable-directives-severity',
'warn'
]
args: ['--report-unused-disable-directives-severity', 'warn']
},
{
label: 'type-aware code quality',
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
OXLINT_SCANS,
diagnosticTouchesAddedLines,
overlapsAddedLines,
parseAddedLineRanges
@@ -41,4 +42,13 @@ describe('changed-code quality line matching', () => {
diagnosticTouchesAddedLines(diagnostic, new Map([[file, [{ start: 24, end: 24 }]]]), root)
).toBe(true)
})
// Why: pinning --config disables nested-config discovery, so root rules that
// mobile/.oxlintrc.json turns off would fail the gate on mobile files.
it('lets the untyped scan discover nested configs instead of pinning the root config', () => {
const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality')
expect(scan.args).not.toContain('--config')
expect(scan.args).not.toContain('--disable-nested-config')
})
})
@@ -1,343 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile']
const LOOP_KINDS = new Set([
ts.SyntaxKind.ForStatement,
ts.SyntaxKind.ForInStatement,
ts.SyntaxKind.ForOfStatement,
ts.SyntaxKind.WhileStatement,
ts.SyntaxKind.DoStatement
])
const ASSIGNMENT_OPERATORS = new Set([
ts.SyntaxKind.EqualsToken,
ts.SyntaxKind.PlusEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.AmpersandAmpersandEqualsToken
])
function normalizeReferenceText(text) {
return text.replaceAll(/\s+/g, '')
}
// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)`
// still resolves to `carry`. `this.x` stops at the property: the class field is
// the accumulator.
function rootReferenceText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return node.expression.kind === ts.SyntaxKind.ThisKeyword
? normalizeReferenceText(node.getText())
: rootReferenceText(node.expression)
}
if (
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node)
) {
return rootReferenceText(node.expression)
}
return undefined
}
function isBufferConcatCall(node) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'concat' &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'Buffer' &&
node.arguments.length > 0 &&
ts.isArrayLiteralExpression(node.arguments[0])
)
}
function enclosingLoop(node) {
for (let current = node.parent; current; current = current.parent) {
if (LOOP_KINDS.has(current.kind)) {
return current
}
}
return undefined
}
// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside
// the loop node, so the initializer does not count as loop-local.
function isDeclaredInsideLoop(declarationStart, loop) {
if (declarationStart < loop.getStart() || declarationStart >= loop.end) {
return false
}
const initializer = ts.isForStatement(loop) ? loop.initializer : undefined
if (!initializer) {
return true
}
return declarationStart < initializer.getStart() || declarationStart >= initializer.end
}
function collectBindingNames(name, into) {
if (ts.isIdentifier(name)) {
into.push(name)
return
}
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
for (const element of name.elements) {
if (ts.isBindingElement(element)) {
collectBindingNames(element.name, into)
}
}
}
}
function collectDeclarations(sourceFile) {
const declarations = new Map()
const record = (identifier, node) => {
const existing = declarations.get(identifier.text)
const start = node.getStart(sourceFile)
if (existing) {
existing.push(start)
} else {
declarations.set(identifier.text, [start])
}
}
const visit = (node) => {
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
const names = []
collectBindingNames(node.name, names)
for (const identifier of names) {
record(identifier, node)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return declarations
}
function collectAssignedRoots(loop) {
const assigned = new Set()
const visit = (node) => {
if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) {
const root = rootReferenceText(node.left)
if (root) {
assigned.add(root)
}
}
ts.forEachChild(node, visit)
}
visit(loop.statement)
return assigned
}
// Unwrap the wrappers a concat result passes through before it lands on the
// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts.
function assignmentTargetOf(call) {
let node = call
let parent = node.parent
while (
parent &&
(ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isNonNullExpression(parent) ||
(ts.isConditionalExpression(parent) && parent.condition !== node))
) {
node = parent
parent = parent.parent
}
if (
parent &&
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
parent.right === node
) {
return {
text: normalizeReferenceText(parent.left.getText()),
root: rootReferenceText(parent.left)
}
}
return undefined
}
function concatOperands(call) {
return call.arguments[0].elements.map((element) => {
const spread = ts.isSpreadElement(element)
const expression = spread ? element.expression : element
return {
spread,
text: normalizeReferenceText(expression.getText()),
root: rootReferenceText(expression)
}
})
}
// A binding reset on every iteration cannot accumulate; only one that outlives
// the iteration carries the cost forward.
function isLoopCarried(root, loop, declarations) {
const starts = declarations.get(root)
if (!starts) {
return true
}
return !starts.some((start) => isDeclaredInsideLoop(start, loop))
}
function quadraticAccumulator(call, loop, declarations, assignedRoots) {
const operands = concatOperands(call)
const target = assignmentTargetOf(call)
// The result feeds straight back into its own input: every iteration re-copies
// everything accumulated so far.
const selfOperand = target
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
: undefined
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
return target.text
}
// No direct self-assignment, so look for a loop-carried operand reassigned
// inside the loop: the concat result reaches it by some other path. Spread
// operands are the sanctioned chunk-list fix, not this bug, so skip them.
for (const operand of operands) {
if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) {
continue
}
if (isLoopCarried(operand.root, loop, declarations)) {
return operand.root
}
}
return undefined
}
export function reportQuadraticBufferConcat(filePath, sourceText) {
if (!sourceText.includes('Buffer.concat')) {
return []
}
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true)
const declarations = collectDeclarations(sourceFile)
const assignedRootsByLoop = new Map()
const reports = []
const seen = new Set()
const visit = (node) => {
if (isBufferConcatCall(node)) {
const loop = enclosingLoop(node)
if (loop) {
let assignedRoots = assignedRootsByLoop.get(loop)
if (!assignedRoots) {
assignedRoots = collectAssignedRoots(loop)
assignedRootsByLoop.set(loop, assignedRoots)
}
const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots)
if (accumulator) {
const start = node.getStart(sourceFile)
if (!seen.has(start)) {
seen.add(start)
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start)
reports.push({
filePath,
line: line + 1,
column: character + 1,
accumulator,
text: node.getText()
})
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}
export function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
// Benchmarks keep the pre-fix shape on purpose so they can measure against it.
if (
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('-benchmark.')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return []
}
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
} else if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
function formatReports(root, reports) {
return reports
.map(
(report) =>
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}`
)
.join('\n')
}
export async function main(root = process.cwd()) {
const reports = []
for (const scanRoot of SCAN_ROOTS) {
const files = await collectSourceFiles(root, path.join(root, scanRoot))
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...reportQuadraticBufferConcat(filePath, sourceText))
}
}
if (reports.length === 0) {
return 0
}
console.error('Buffer.concat must not rebuild a loop-carried accumulator.')
console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).')
console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.')
console.error('')
console.error(formatReports(root, reports))
return 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -1,146 +0,0 @@
import { describe, expect, it } from 'vitest'
import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs'
describe('check-quadratic-buffer-concat', () => {
it('reports an accumulator rebuilt from itself in a for-of loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }'
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('acc')
})
it('reports the accumulator when it trails the new chunk', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }'
)
expect(reports).toHaveLength(1)
})
it('reports a spread of the accumulator into its own concat', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }'
)
expect(reports).toHaveLength(1)
})
it('reports the real pre-fix ai-vault stream carry', () => {
const reports = reportQuadraticBufferConcat(
'session-scanner-parse-cache.ts',
`async function read(stream) {
let remainder = null
for await (const chunk of stream) {
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
remainder = Buffer.from(data.subarray(lineStart))
}
}`
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('remainder')
})
it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => {
const reports = reportQuadraticBufferConcat(
'agent-hook-listener.ts',
`function read(fd, size) {
let carryBytes = Buffer.alloc(0)
while (bytesRead < size) {
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
carryBytes = combined.subarray(0, firstNewline)
}
}`
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('carryBytes')
})
it('accepts the chunk-list fix that joins once after the loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
)
expect(reports).toHaveLength(0)
})
it('accepts spreading a carry chunk list, which is the sanctioned fix', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
`let carryChunks = []
while (scanEnd > 0) {
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}`
)
expect(reports).toHaveLength(0)
})
it('accepts a concat whose result never outlives the iteration', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
)
expect(reports).toHaveLength(0)
})
it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
)
expect(reports).toHaveLength(0)
})
it('reports an accumulator declared in a classic for initializer', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }'
)
expect(reports).toHaveLength(1)
})
it('reports a class field accumulator rebuilt inside a loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }'
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('this.pending')
})
it('reports an accumulator guarded by an emptiness ternary', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }'
)
expect(reports).toHaveLength(1)
})
it('ignores a per-event accumulator with no enclosing loop', () => {
const reports = reportQuadraticBufferConcat(
'scrcpy-stream-session.ts',
'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }'
)
expect(reports).toHaveLength(0)
})
it('ignores files with no Buffer.concat at all', () => {
expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0)
})
})
@@ -1,87 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
export {
plainClassName,
reportUnstyledScrollbars
} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
export function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (relative.includes('.test.') || relative.includes('.spec.')) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
} else if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
async function collectUnstyledScrollbarReports(root) {
const sourceRoot = path.join(root, 'src', 'renderer', 'src')
const files = await collectSourceFiles(root, sourceRoot)
const reports = []
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...reportUnstyledScrollbars(filePath, sourceText))
}
return reports
}
function formatReports(root, reports) {
return reports
.map(
(report) =>
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}`
)
.join('\n')
}
export async function main(root = process.cwd()) {
const reports = await collectUnstyledScrollbarReports(root)
if (reports.length === 0) {
return 0
}
console.error('Renderer vertical scroll containers must use an Orca scrollbar style.')
console.error('Put the scrollbar class in the same class literal as the vertical overflow class.')
console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.')
console.error('')
console.error(formatReports(root, reports))
return 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -1,199 +0,0 @@
import { describe, expect, it } from 'vitest'
import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs'
describe('check-styled-scrollbars', () => {
it('reports renderer vertical scroll containers without an Orca scrollbar style', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-y-auto" /> }'
)
expect(reports).toHaveLength(1)
})
it('accepts obvious styled vertical scroll containers', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="max-h-64 overflow-auto scrollbar-none" /> }'
)
expect(reports).toHaveLength(1)
})
it('fails closed when a separate class composer argument supplies the scrollbar style', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto', 'scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts static class composer arguments when the same literal is styled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={cn('max-h-64 overflow-y-auto scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(0)
})
it('fails closed when a scrollbar class is only conditionally present', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ enabled }) { return <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ enabled }) { return <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} /> }"
)
expect(reports).toHaveLength(0)
})
it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={identity('overflow-y-auto')} /> }"
)
expect(reports).toHaveLength(1)
})
it('does not require a vertical scrollbar style for horizontal-only overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <pre className="max-w-full overflow-x-auto" /> }'
)
expect(reports).toHaveLength(0)
})
it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="overflow-y-auto md:scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(1)
})
it('accepts matching responsive overflow and scrollbar variants', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="md:overflow-y-auto md:scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('accepts unconditional scrollbar styles for responsive overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="md:overflow-y-auto scrollbar-sleek" /> }'
)
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div style={{ overflowY: 'auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts inline vertical overflow with a stable Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="scrollbar-editor" style={{ overflow: \'auto\' }} /> }'
)
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
for (const classNameExpression of [
"enabled && 'scrollbar-sleek'",
"enabled ? 'scrollbar-sleek' : undefined",
"enabled || 'scrollbar-sleek'",
"enabled ?? 'scrollbar-sleek'"
]) {
const reports = reportUnstyledScrollbars(
'Example.tsx',
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
)
expect(reports, classNameExpression).toHaveLength(1)
}
})
it('reports logical inline style spreads without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example({ open }) { return <div style={{ ...(open && { overflowY: 'auto' }) }} /> }"
)
expect(reports).toHaveLength(1)
})
it('reports JSX spread className props with unstyled vertical overflow', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div {...{ className: 'overflow-y-auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('accepts JSX spread className props when the same literal is styled', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} /> }"
)
expect(reports).toHaveLength(0)
})
it('uses later spread className props over earlier explicit className props', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
'export function Example() { return <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} /> }'
)
expect(reports).toHaveLength(1)
})
it('supports variant helper className config', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} /> }"
)
expect(reports).toHaveLength(0)
})
it('normalizes Tailwind variants and important prefixes before matching', () => {
expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto')
expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto')
expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor')
expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor')
})
})
@@ -12,11 +12,10 @@
// other work would be a regression, not a win.
//
// Run with: node config/scripts/claude-usage-yield-benchmark.mjs
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { readdirSync, statSync } from 'node:fs'
const REPO_ROOT = new URL('../..', import.meta.url)
const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10')
@@ -196,6 +196,7 @@ try {
// Each case is (label, argv, env). The runtime-dependent ones point at an
// empty user-data dir so both arms get the same deterministic answer.
const isolated = { ORCA_USER_DATA_PATH: userDataPath }
/** @type {Array<[string, string[], Record<string, string>]>} */
const cases = [
['orca --help', ['--help'], {}],
['orca help worktree', ['help', 'worktree'], {}],
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
@@ -38,15 +39,39 @@ describe('electron-builder config', () => {
'!skill-stubs{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!examples{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
// anything without an explicit `!` entry — examples/ landed without one and shipped
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
it('keeps plugin authoring examples out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
// copyFiles() prepends this itself once the pattern list is all-negation.
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const authoringOnly of [
'examples/plugins/hostile-panel/panel.html',
'examples/plugins/hostile-panel/orca-plugin.json',
'examples/plugins/hello-orca/main.mjs',
'examples/plugins/hello-orca/orca-plugin.json'
]) {
expect(packs(authoringOnly)).toBe(false)
}
// The negation stays anchored at the app root, so nested `examples` segments still ship.
expect(packs('out/main/examples/index.js')).toBe(true)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
@@ -39,4 +40,12 @@ describe('Electron Vite output contract', () => {
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
expect(devRunner).toContain("'Electron Helper.app',")
expect(devRunner).toContain(
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
)
})
})
@@ -200,7 +200,7 @@ export async function setupTerminal(page, repoPath, logPhase) {
return pane?.container?.dataset?.ptyId ?? null
})
)
logPhase('setup.pty-bound', `ptyId=${ptyId}`)
logPhase('setup.pty-bound', `ptyId=${String(ptyId)}`)
return ptyId
}
@@ -1,5 +1,6 @@
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
export function mergeLocaleKeyOverrides(base) {
const merged = { ...base }
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
// KO split overrides can share keys with zh/ja repairs; merge per locale.
merged[key] = { ...merged[key], ...overrides }
}
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
merged[key] = { ...merged[key], ...overrides }
}
return merged
}
@@ -2231,9 +2231,6 @@
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
"ko": "자동화"
},
@@ -0,0 +1,18 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
zh: '完全磁盘访问权限'
}
}
@@ -0,0 +1,19 @@
const LOCALIZED_PROSE_TERM_KEYS = new Set([
'auto.hooks.useMacosTccPromptNotice.description',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
])
const LOCALIZABLE_PROSE_TERMS = new Set([
'Agent',
'Agents',
'agent',
'agents',
'Terminal',
'Terminals',
'terminal',
'terminals'
])
export function isLocalizedProseTermContext(term, key) {
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
}
@@ -1,4 +1,5 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const LOCALES = ['es', 'ja', 'ko', 'zh']
const KEYS = [
'auto.hooks.useMacosTccPromptNotice.title',
'auto.hooks.useMacosTccPromptNotice.description',
'auto.hooks.useMacosTccPromptNotice.openSettings',
'auto.hooks.useMacosTccPromptNotice.dismiss',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
]
function readCatalog(locale) {
return JSON.parse(
fs.readFileSync(
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
'utf8'
)
)
}
function getValue(catalog, key) {
return key.split('.').reduce((value, part) => value[part], catalog)
}
describe('macOS TCC prompt localization', () => {
it('survives the canonical catalog repair policy', () => {
const english = readCatalog('en')
for (const locale of LOCALES) {
const catalog = readCatalog(locale)
for (const key of KEYS) {
const enValue = getValue(english, key)
const localeValue = getValue(catalog, key)
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
}
}
})
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
expect(
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('전체 디스크 접근 권한')
expect(
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('完全磁盘访问权限')
})
})
@@ -40,7 +40,10 @@ describe('Windows SSH relay node-pty console-list patch', () => {
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(tamperedPatch.root)
writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`)
writeFileSync(
tamperedPatch.agentPath,
`${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift`
)
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
})
})
@@ -63,6 +63,18 @@ describe('orchestration skill guidance', () => {
expect(migration).toContain('task-list --run run_legacy_local')
expect(migration).toContain('Read-only inspection never consumes legacy mail')
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
expect(migration).toContain('does not cancel the prior assignment')
expect(migration).toContain('invalidate its worktree')
expect(migration).toContain('discard filesystem changes')
expect(migration).toContain('leave it as the only editor in that worktree')
expect(migration).toContain('observe it manually, read-only')
expect(migration).toContain('until it reaches a stable handoff point')
expect(migration).toContain('visible activity is a reason to keep observing')
expect(migration).toContain(
'Never launch a replacement editor in the same worktree while the legacy worker may still write there.'
)
expect(migration).toContain('if remaining work needs new lifecycle supervision')
expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`')
})
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
@@ -0,0 +1,58 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
const oxlintPackageDirectory = path.dirname(
createRequire(import.meta.url).resolve('oxlint/package.json')
)
const oxlintPath = path.join(oxlintPackageDirectory, 'bin', 'oxlint')
export function runOxlintPluginOnSource({
pluginName,
pluginPath,
rules,
source,
extension = 'tsx'
}) {
const directory = mkdtempSync(path.join(tmpdir(), `orca-${pluginName}-lint-`))
const sourcePath = path.join(directory, `sample.${extension}`)
const configPath = path.join(directory, 'oxlint.json')
try {
writeFileSync(sourcePath, source)
writeFileSync(
configPath,
JSON.stringify({
plugins: [],
categories: {
correctness: 'off',
suspicious: 'off',
pedantic: 'off',
perf: 'off',
style: 'off',
restriction: 'off',
nursery: 'off'
},
jsPlugins: [{ name: pluginName, specifier: pluginPath }],
rules
})
)
const result = spawnSync(
process.execPath,
[oxlintPath, '--config', configPath, '--format', 'json', sourcePath],
{ encoding: 'utf8' }
)
if (result.error) {
throw result.error
}
if (!result.stdout.trim()) {
throw new Error(result.stderr || `${pluginName} did not produce Oxlint output`)
}
return JSON.parse(result.stdout).diagnostics
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter E2E-relevant paths'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
describe('PR E2E gate contract', () => {
it('keeps E2E advisory while the suite is red on main', () => {
// Why: pin the deliberate choice so it reads as intentional rather than as
// the "forgot to wire the gate" bug this file originally caught. Gating on a
// suite that fails every scheduled run would block the PRs that fix it.
// Flipping to blocking means updating this expectation too — see the comment
// on verify's Require-successful-checks step for the exact wiring.
expect(prWorkflow.jobs.verify.needs).not.toContain('e2e')
expect(verifyStep.env.E2E).toBeUndefined()
expect(verifyStep.run).not.toContain('$E2E')
})
it('runs E2E only when the detector says the PR touches E2E paths', () => {
// Why: without this the job could lose its filter and run on every PR — the
// cost the path filter exists to avoid — while the gate assertions above
// stay green.
expect(prWorkflow.jobs.e2e.needs).toBe('e2e-paths')
expect(prWorkflow.jobs.e2e.if).toBe("needs.e2e-paths.outputs.should_run == 'true'")
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
})
it('enforces every job verify depends on', () => {
// Why: derive from verify.needs rather than hardcoding, so adding a required
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
const strictLoop = verifyStep.run.slice(0, verifyStep.run.indexOf('done'))
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
expect(strictLoop).toContain(`"$${envVar}"`)
}
})
it('matches the Playwright config where it actually lives', () => {
// Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather
// than inside it. A bare `playwright.` prefix matches no tracked file, so
// editing the runner config would silently skip E2E.
expect(filterStep.run).toContain('tests/playwright\\.')
expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./)
})
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('set -euo pipefail')
})
})
@@ -98,7 +98,7 @@ export async function publishCompleteDraftReleases({
for (const release of candidates) {
const tag = release.tag_name
if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) {
if (!(await Promise.resolve(isDraftBuiltFromCurrentRef({ tag, release })))) {
const reason = 'tag is not built from the current release ref'
skipped.push({ tag, reason })
log(`Skipping stale RC draft release ${tag}: ${reason}`)
@@ -0,0 +1,113 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/quadratic-buffer-concat.mjs')
function lintSource(source) {
return runOxlintPluginOnSource({
pluginName: 'quadratic-buffer-concat',
pluginPath,
source,
extension: 'ts',
rules: {
'quadratic-buffer-concat/no-loop-carried-concat': 'warn'
}
})
}
const violations = [
[
'self accumulator',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }',
'acc'
],
[
'trailing accumulator',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }',
'acc'
],
[
'spread self accumulator',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }',
'acc'
],
[
'indirect stream carry',
`async function read(stream) {
let remainder = null
for await (const chunk of stream) {
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
remainder = Buffer.from(data.subarray(lineStart))
}
}`,
'remainder'
],
[
'indirect transcript carry',
`function read(fd, size) {
let carryBytes = Buffer.alloc(0)
while (bytesRead < size) {
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
carryBytes = combined.subarray(0, firstNewline)
}
}`,
'carryBytes'
],
[
'classic for initializer',
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }',
'acc'
],
[
'class field accumulator',
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }',
'this.pending'
],
[
'guarded accumulator',
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }',
'acc'
]
]
const accepted = [
[
'single concat after loop',
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
],
[
'spread chunk list',
`let carryChunks = []
while (scanEnd > 0) {
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}`
],
[
'iteration-local result',
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
],
[
'iteration-local accumulator',
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
],
[
'no enclosing loop',
'class S { handle(chunk) { const buffer = Buffer.concat([this.pending, chunk]); this.pending = parse(buffer).pending } }'
],
['no Buffer concat', 'export const x = 1']
]
describe('quadratic Buffer.concat Oxlint plugin', () => {
it.each(violations)('reports %s', (_name, source, accumulator) => {
const diagnostics = lintSource(source)
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0].message).toContain(accumulator)
})
it.each(accepted)('accepts %s', (_name, source) => {
expect(lintSource(source)).toEqual([])
})
})
@@ -0,0 +1,116 @@
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { plainClassName } from '../oxlint-plugins/renderer-scrollbar-style.mjs'
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/renderer-scrollbar-style.mjs')
function lintSource(source) {
return runOxlintPluginOnSource({
pluginName: 'renderer-scrollbar-style',
pluginPath,
source,
rules: {
'renderer-scrollbar-style/require-styled-vertical-scrollbar': 'warn'
}
})
}
const violations = [
['unstyled class', 'export const X = () => <div className="max-h-64 overflow-y-auto" />'],
[
'unstyled suffix-important class',
'export const X = () => <div className="max-h-64 overflow-y-auto!" />'
],
[
'unknown scrollbar class',
'export const X = () => <div className="overflow-auto scrollbar-none" />'
],
[
'separate class composer arguments',
"export const X = () => <div className={cn('overflow-y-auto', 'scrollbar-sleek')} />"
],
[
'conditional scrollbar',
"export const X = ({ enabled }) => <div className={cn('overflow-y-auto', enabled && 'scrollbar-sleek')} />"
],
[
'arbitrary class wrapper',
"export const X = () => <div className={identity('overflow-y-auto')} />"
],
[
'mismatched responsive variants',
'export const X = () => <div className="overflow-y-auto md:scrollbar-sleek" />'
],
['inline overflow', "export const X = () => <div style={{ overflowY: 'auto' }} />"],
[
'logical inline style spread',
"export const X = ({ open }) => <div style={{ ...(open && { overflowY: 'auto' }) }} />"
],
['JSX spread class', "export const X = () => <div {...{ className: 'overflow-y-auto' }} />"],
[
'later spread override',
'export const X = () => <div className="scrollbar-sleek" {...{ className: \'overflow-y-auto\' }} />'
]
]
const accepted = [
[
'styled vertical class',
'export const X = () => <div className="overflow-auto scrollbar-sleek" />'
],
[
'styled suffix-important classes',
'export const X = () => <div className="overflow-y-auto! scrollbar-sleek!" />'
],
[
'same composer literal',
"export const X = () => <div className={cn('overflow-y-auto scrollbar-sleek')} />"
],
[
'same conditional literal',
"export const X = ({ enabled }) => <div className={cn(enabled && 'overflow-y-auto scrollbar-sleek')} />"
],
['horizontal-only overflow', 'export const X = () => <pre className="overflow-x-auto" />'],
[
'matching responsive variants',
'export const X = () => <div className="md:overflow-y-auto md:scrollbar-sleek" />'
],
[
'unconditional scrollbar',
'export const X = () => <div className="md:overflow-y-auto scrollbar-sleek" />'
],
[
'styled inline overflow',
'export const X = () => <div className="scrollbar-editor" style={{ overflow: \'auto\' }} />'
],
[
'styled JSX spread class',
"export const X = () => <div {...{ className: 'overflow-y-auto scrollbar-sleek' }} />"
],
[
'variant configuration',
"export const X = () => <div className={buttonVariants({ className: 'overflow-y-auto scrollbar-sleek' })} />"
]
]
describe('renderer scrollbar style Oxlint plugin', () => {
it.each(violations)('reports %s', (_name, source) => {
expect(lintSource(source)).toHaveLength(1)
})
it.each(accepted)('accepts %s', (_name, source) => {
expect(lintSource(source)).toEqual([])
})
it.each([
['md:overflow-y-auto', 'overflow-y-auto'],
['[&:hover]:overflow-y-auto', 'overflow-y-auto'],
['md:!scrollbar-editor', 'scrollbar-editor'],
['!scrollbar-editor', 'scrollbar-editor'],
['overflow-y-auto!', 'overflow-y-auto'],
['md:scrollbar-editor!', 'scrollbar-editor']
])('normalizes %s', (token, expected) => {
expect(plainClassName(token)).toBe(expected)
})
})
+12 -4
View File
@@ -128,10 +128,8 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
// v7: give the terminal daemon helper an Orca-specific TCC identity.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -155,6 +153,7 @@ function prepareMacDevElectronApp() {
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
const helperBundleId = `${bundleId}.helper`
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -205,9 +204,18 @@ function prepareMacDevElectronApp() {
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
const helperPlistPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Helper.app',
'Contents',
'Info.plist'
)
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
+1 -1
View File
@@ -572,7 +572,7 @@ async function main() {
if (options.output) {
mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true })
writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[idle-cpu] wrote ${options.output}`)
console.log(`[idle-cpu] wrote ${String(options.output)}`)
}
console.log(
JSON.stringify(
@@ -1,312 +0,0 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const STYLED_SCROLLBAR_CLASSES = new Set(
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
)
// Why: vertical scroll is where native scrollbar drift keeps recurring. The
// guard intentionally ignores horizontal-only overflow.
const VERTICAL_SCROLL_CLASSES = new Set(
'overflow-auto overflow-scroll overflow-y-auto overflow-y-scroll'.split(' ')
)
const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll'])
export function plainClassName(token) {
const normalizedToken = token.startsWith('!') ? token.slice(1) : token
const parts = []
let bracketDepth = 0
let currentPart = ''
for (const char of normalizedToken) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
parts.push(currentPart)
currentPart = ''
continue
}
currentPart += char
}
parts.push(currentPart)
const className = parts.at(-1) ?? ''
return className.startsWith('!') ? className.slice(1) : className
}
function classTokenParts(token) {
const variants = []
let bracketDepth = 0
let currentPart = ''
for (const char of token.startsWith('!') ? token.slice(1) : token) {
if (char === '[') {
bracketDepth += 1
} else if (char === ']') {
bracketDepth = Math.max(0, bracketDepth - 1)
}
if (char === ':' && bracketDepth === 0) {
variants.push(currentPart)
currentPart = ''
continue
}
currentPart += char
}
return { className: plainClassName(token), variants: variants.filter(Boolean) }
}
function classTokens(text) {
return text.split(/\s+/).filter(Boolean).map(classTokenParts)
}
function sameVariants(left, right) {
return left.length === right.length && left.every((variant, index) => variant === right[index])
}
function literalHasScrollbarForVertical(text, verticalToken) {
return classTokens(text).some((candidate) => {
if (!STYLED_SCROLLBAR_CLASSES.has(candidate.className)) {
return false
}
return (
candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants)
)
})
}
function uncoveredVerticalClass(text) {
return classTokens(text).find((token) => {
return (
VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token)
)
})
}
function reportAt(node, filePath, sourceFile, text) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return {
filePath,
line: position.line + 1,
column: position.character + 1,
text
}
}
function stringLiteralTexts(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [node.text]
}
if (!ts.isTemplateExpression(node)) {
return []
}
return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)]
}
function collectClassLiteralReports(node, filePath, sourceFile) {
const reports = []
function visit(current) {
for (const text of stringLiteralTexts(current)) {
const uncovered = uncoveredVerticalClass(text)
if (uncovered) {
reports.push(reportAt(current, filePath, sourceFile, uncovered.className))
}
}
ts.forEachChild(current, visit)
}
visit(node)
return reports
}
function expressionHasStyledScrollbarLiteral(node) {
let hasStyledScrollbar = false
function visit(current) {
if (hasStyledScrollbar) {
return
}
if (
stringLiteralTexts(current).some((text) =>
classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className))
)
) {
hasStyledScrollbar = true
return
}
// Why: a scrollbar literal that only renders on some branches must not be
// treated as covering an unconditional inline overflow. Skip conditional
// and short-circuit expressions when proving unconditional coverage.
if (ts.isConditionalExpression(current)) {
return
}
if (
ts.isBinaryExpression(current) &&
(current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
current.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
) {
return
}
ts.forEachChild(current, visit)
}
visit(node)
return hasStyledScrollbar
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) {
return name.text
}
if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) {
return name.expression.text
}
return undefined
}
function styleValueIsVerticalScroll(propertyName, value) {
const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (parts.length === 0) {
return false
}
if (propertyName === 'overflowY' || propertyName === 'overflow-y') {
return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0])
}
if (propertyName !== 'overflow') {
return false
}
const verticalValue = parts.length > 1 ? parts[1] : parts[0]
return VERTICAL_SCROLL_STYLE_VALUES.has(verticalValue)
}
function collectStyleReports(node, filePath, sourceFile) {
const reports = []
function visit(current) {
if (ts.isPropertyAssignment(current)) {
const propertyName = propertyNameText(current.name)
for (const value of propertyName ? stringLiteralTexts(current.initializer) : []) {
if (styleValueIsVerticalScroll(propertyName, value)) {
reports.push(reportAt(current, filePath, sourceFile, 'inline vertical scroll'))
}
}
ts.forEachChild(current.initializer, visit)
return
}
ts.forEachChild(current, visit)
}
visit(node)
return reports
}
function jsxAttributeName(attribute) {
return ts.isIdentifier(attribute.name) ? attribute.name.text : undefined
}
function jsxAttributeExpression(attribute) {
if (ts.isStringLiteral(attribute.initializer)) {
return attribute.initializer
}
if (attribute.initializer && ts.isJsxExpression(attribute.initializer)) {
return attribute.initializer.expression
}
return undefined
}
function spreadPropExpressions(node, propName) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
return spreadPropExpressions(node.expression, propName)
}
if (ts.isConditionalExpression(node)) {
return [
...spreadPropExpressions(node.whenTrue, propName),
...spreadPropExpressions(node.whenFalse, propName)
]
}
if (ts.isBinaryExpression(node)) {
return [
...spreadPropExpressions(node.left, propName),
...spreadPropExpressions(node.right, propName)
]
}
if (!ts.isObjectLiteralExpression(node)) {
return []
}
return node.properties.flatMap((property) => {
if (ts.isSpreadAssignment(property)) {
return spreadPropExpressions(property.expression, propName)
}
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propName) {
return [property.initializer]
}
return []
})
}
function jsxElementReports(node, filePath, sourceFile) {
const reports = []
let classExpression
const styleExpressions = []
for (const attribute of node.attributes.properties) {
if (ts.isJsxSpreadAttribute(attribute)) {
// Why: at runtime React applies attributes in source order, so a later
// spread that supplies className overrides an earlier explicit className.
const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1)
if (spreadClassExpression) {
classExpression = spreadClassExpression
}
styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style'))
} else if (jsxAttributeName(attribute) === 'className') {
classExpression = jsxAttributeExpression(attribute)
} else if (jsxAttributeName(attribute) === 'style') {
const expression = jsxAttributeExpression(attribute)
if (expression) {
styleExpressions.push(expression)
}
}
}
if (classExpression) {
reports.push(...collectClassLiteralReports(classExpression, filePath, sourceFile))
}
if (classExpression && expressionHasStyledScrollbarLiteral(classExpression)) {
return reports
}
for (const expression of styleExpressions) {
reports.push(...collectStyleReports(expression, filePath, sourceFile))
}
return reports
}
export function reportUnstyledScrollbars(filePath, sourceText) {
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
)
const reports = []
function visit(node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
reports.push(...jsxElementReports(node, filePath, sourceFile))
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}
@@ -1,6 +1,54 @@
#!/usr/bin/env node
// Mirrors the complete pre/post stripTerminalControl paths at their production size caps.
// Compares legacy, slice-run, and production-adaptive control stripping.
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import nodeModule from 'node:module'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
if (!process.execArgv.includes('--experimental-transform-types')) {
const result = spawnSync(
process.execPath,
['--experimental-transform-types', '--no-warnings', import.meta.filename],
{ stdio: 'inherit' }
)
process.exit(result.status ?? 1)
}
nodeModule.registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) {
const candidate = new URL(`${specifier}.ts`, context.parentURL)
if (existsSync(fileURLToPath(candidate))) {
return { url: candidate.href, shortCircuit: true }
}
}
return nextResolve(specifier, context)
}
})
const PRODUCTION_SOURCE = readFileSync(
new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url),
'utf8'
)
for (const marker of [
'export function stripTerminalControl(data: string): string',
'strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT',
'output += withoutAnsi.slice(runStart, index)',
// Retuning either density constant changes which fixtures sit above/below the trigger, so
// pin the literals rather than the names: a silent retune would leave the sub-threshold
// fixture measuring a boundary that no longer exists.
'const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64',
'const CONTROL_DENSITY_FALLBACK_COUNT = 32'
]) {
if (!PRODUCTION_SOURCE.includes(marker)) {
throw new Error(`terminal-control-stripping.ts no longer contains \`${marker}\``)
}
}
const { stripTerminalControl: stripAdaptive } = await import(
new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url).href
)
const ESC = String.fromCharCode(0x1b)
const BEL = String.fromCharCode(0x07)
@@ -15,6 +63,9 @@ const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp(
const HISTORY_LIMIT = 300
const SCAN_LIMIT = 4096
const SAMPLE_ID_LENGTH = 24
// Mirrors terminal-control-stripping.ts; the marker guard above fails if either is retuned.
const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64
const CONTROL_DENSITY_FALLBACK_COUNT = 32
const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501')
let resultChecksum = 0
let validatedPairs = 0
@@ -75,6 +126,25 @@ function stripSliceRuns(data) {
return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart)
}
function adaptiveFallbackIndex(data) {
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
let strippedInBlock = 0
let blockEnd = 64
for (let index = 0; index < withoutAnsi.length; index += 1) {
if (index === blockEnd) {
strippedInBlock = 0
blockEnd += 64
}
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
strippedInBlock += 1
if (strippedInBlock === 32) {
return index
}
}
}
return -1
}
function fixedSampleId(sampleId) {
return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH)
}
@@ -95,6 +165,26 @@ function makeTuiFixture(length, sampleId, strippedControl) {
return text + 'x'.repeat(length - text.length)
}
// 31 controls per 64-unit block: one below the fallback trigger, so the adaptive path keeps
// slice-run bookkeeping on a shape dense enough to lose to the per-character legacy. This is the
// worst surviving case; it exists so the narrowed adverse window stays visible instead of hiding
// behind the 50% fixture, where the fallback fires and wins.
function makeSubThresholdDenseFixture(length, sampleId, strippedControl) {
const id = fixedSampleId(sampleId)
const units = []
for (let index = 0; index < length; index += 1) {
const blockOffset = index % CONTROL_DENSITY_BLOCK_CODE_UNITS
if (index < id.length) {
units.push(id[index])
} else if (blockOffset % 2 === 1 && blockOffset < (CONTROL_DENSITY_FALLBACK_COUNT - 1) * 2) {
units.push(strippedControl)
} else {
units.push(String.fromCharCode(97 + (index % 26)))
}
}
return units.join('')
}
function makeDenseFixture(length, sampleId, strippedControl) {
const prefix = `\x1b[35m${fixedSampleId(sampleId)}`
const suffix = '\x1b[0m'
@@ -120,33 +210,35 @@ function consumeOutput(output) {
resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2))
}
function recordPair(fixture, sampleId, perCharFirst, samples) {
const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02')
const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01')
if (
perCharFixture === sliceRunsFixture ||
perCharFixture.length !== fixture.length ||
sliceRunsFixture.length !== fixture.length
) {
const IMPLEMENTATIONS = [
['perChar', stripPerChar, '\x01'],
['sliceRuns', stripSliceRuns, '\x02'],
['adaptive', stripAdaptive, '\x03']
]
function recordRotation(fixture, sampleId, lead, samples) {
const inputs = IMPLEMENTATIONS.map(([name, strip, control]) => ({
name,
strip,
input: fixture.make(sampleId, control)
}))
if (inputs.some(({ input }) => input.length !== fixture.length)) {
throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`)
}
let perCharResult
let sliceRunsResult
if (perCharFirst) {
perCharResult = measure(stripPerChar, perCharFixture)
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
} else {
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
perCharResult = measure(stripPerChar, perCharFixture)
const results = new Map()
for (let offset = 0; offset < inputs.length; offset += 1) {
const entry = inputs[(lead + offset) % inputs.length]
results.set(entry.name, measure(entry.strip, entry.input))
}
if (perCharResult.output !== sliceRunsResult.output) {
const outputs = [...results.values()].map(({ output }) => output)
if (new Set(outputs).size !== 1) {
throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`)
}
consumeOutput(perCharResult.output)
consumeOutput(sliceRunsResult.output)
for (const [name, result] of results) {
consumeOutput(result.output)
samples[name].push(result.elapsed)
}
validatedPairs += 1
samples.perChar.push(perCharResult.elapsed)
samples.sliceRuns.push(sliceRunsResult.elapsed)
}
const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length
@@ -171,32 +263,68 @@ const fixtures = [
label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`,
length: SCAN_LIMIT,
make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control)
},
{
label: `${SCAN_LIMIT} scan 31/block C0`,
length: SCAN_LIMIT,
make: (sampleId, control) => makeSubThresholdDenseFixture(SCAN_LIMIT, sampleId, control)
}
]
const selectorFixtures = [
{ label: '31 controls', data: `${'\x01'.repeat(31)}${'a'.repeat(33)}`, expected: -1 },
{ label: '32 controls', data: `${'\x01'.repeat(32)}${'a'.repeat(32)}`, expected: 31 },
{
label: 'block reset at 64',
data: `${'\x01'.repeat(31)}${'a'.repeat(33)}${'\x01'.repeat(32)}`,
expected: 95
},
{
label: 'late dense block',
data: `${'a'.repeat(64 * 3)}${'\x01'.repeat(32)}tail`,
expected: 223
},
{
label: 'routine TUI',
data: makeTuiFixture(SCAN_LIMIT, 'selector', '\x01'),
expected: -1
},
{
label: '31/block never triggers',
data: makeSubThresholdDenseFixture(SCAN_LIMIT, 'selector', '\x01'),
expected: -1
}
]
for (const fixture of selectorFixtures) {
const actual = adaptiveFallbackIndex(fixture.data)
if (actual !== fixture.expected) {
throw new Error(`${fixture.label} fallback index ${actual}, expected ${fixture.expected}`)
}
}
const pad = (value, width) => String(value).padStart(width)
console.log('Complete stripTerminalControl path. Lower is better.')
console.log(`iterations=${ITERATIONS} (${ITERATIONS * 3} rotated samples/implementation, median)`)
console.log(
`iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)`
)
console.log(
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}`
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('adaptive', 11)} ${pad('vs legacy', 10)} ${pad('vs slice', 9)}`
)
for (const fixture of fixtures) {
const samples = { perChar: [], sliceRuns: [] }
const samples = { perChar: [], sliceRuns: [], adaptive: [] }
for (let index = 0; index < ITERATIONS; index += 1) {
const orders = index % 2 === 0 ? [true, false] : [false, true]
for (const perCharFirst of orders) {
const orderLabel = perCharFirst ? 'per-first' : 'slice-first'
recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples)
for (let lead = 0; lead < IMPLEMENTATIONS.length; lead += 1) {
recordRotation(fixture, `${index}:lead-${lead}`, lead, samples)
}
}
const perChar = median(samples.perChar)
const sliceRuns = median(samples.sliceRuns)
const adaptive = median(samples.adaptive)
console.log(
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}`
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(adaptive * 1000).toFixed(1)} us`, 11)} ${pad(`${(perChar / adaptive).toFixed(2)}x`, 10)} ${pad(`${(sliceRuns / adaptive).toFixed(2)}x`, 9)}`
)
}
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
console.log(
`\nvalidated=${validatedPairs} measured rotations, result checksum=${resultChecksum >>> 0}`
)
console.log(`selector checks=${selectorFixtures.length}`)
console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.')
@@ -8,10 +8,8 @@
// `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the
// same per-code-point walk over the whole payload a second time.
//
// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point
// string, chunk text taken as data.slice(chunkStart, end), and a gate of
// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a
// string's UTF-8 length is never below its UTF-16 length.
// The fix: one charCodeAt scan computing UTF-8 width inline, slices for chunk text,
// and bounded byte probes when UTF-16 length alone cannot prove fit or overflow.
//
// BOTH arms run the complete production path, encodeTerminalStreamText included, and
// their emitted frames (base64 + seq + opcode) are compared before any timing, so an
@@ -48,11 +46,13 @@ nodeModule.registerHooks({
})
const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40')
const GATE_ITERATIONS = Number(process.env.ORCA_FRAME_GATE_BENCH_ITERATIONS ?? '2000')
const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8')
const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6')
for (const [name, value] of [
['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS],
['ORCA_FRAME_GATE_BENCH_ITERATIONS', GATE_ITERATIONS],
['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP],
['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS]
]) {
@@ -73,12 +73,19 @@ const CLIPBOARD_SOURCE = readFileSync(
'utf8'
)
// Why re-read the sources: this benchmark's claim is that the OLD arm paid a
// measureClipboardTextByteLength call per code point and the NEW one pays a single
// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming
// the function would satisfy a substring check.
// Match executable source markers so a stale benchmark fails instead of misleading.
for (const [source, label, marker] of [
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"],
[
CHUNK_SOURCE,
'terminal-output-frame-chunks.ts',
'export function exceedsTerminalStreamChunkBytes(data: string): boolean'
],
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS'],
[
CHUNK_SOURCE,
'terminal-output-frame-chunks.ts',
'terminalStreamByteLength(data.slice(start, end))'
],
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'],
[CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'],
[CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('],
@@ -103,10 +110,17 @@ const { measureClipboardTextByteLength } = await import(
const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import(
new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href
)
const { iterateTerminalOutputFrameChunks } = await import(
const { exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks } = await import(
new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href
)
function previousGate(data) {
return (
data.length > TERMINAL_STREAM_CHUNK_BYTES ||
Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES
)
}
// Pre-fix arm: the exact code that shipped, including the second full walk in the gate.
function* iterateBefore(data, meta) {
const rawLength = meta?.rawLength ?? data.length
@@ -260,6 +274,11 @@ const fixtures = [
data: repeatTo(LONE_HIGH, 64 * 1024),
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: 'late-wide gate miss (3 chunks)',
data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`,
meta: (data) => ({ seq: 5_000_000, rawLength: data.length })
},
{
label: 'ascii 512KiB (snapshot chunking)',
data: 'x'.repeat(512 * 1024),
@@ -309,6 +328,38 @@ function measureInterleaved(data, meta) {
return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) }
}
let gateChecksum = 0
function drainGate(gate, data) {
gateChecksum = Math.imul(gateChecksum ^ (gate(data) ? 1 : 0), 16777619) >>> 0
}
function measureGateInterleaved(data) {
for (let index = 0; index < WARMUP * 10; index += 1) {
drainGate(previousGate, data)
drainGate(exceedsTerminalStreamChunkBytes, data)
}
const previousSamples = []
const boundedSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
const run = (gate, samples) => {
const start = performance.now()
for (let index = 0; index < GATE_ITERATIONS; index += 1) {
drainGate(gate, data)
}
samples.push((performance.now() - start) / GATE_ITERATIONS)
}
if (round % 2 === 0) {
run(previousGate, previousSamples)
run(exceedsTerminalStreamChunkBytes, boundedSamples)
} else {
run(exceedsTerminalStreamChunkBytes, boundedSamples)
run(previousGate, previousSamples)
}
}
return { previousMs: median(previousSamples), boundedMs: median(boundedSamples) }
}
const pad = (value, width) => String(value).padStart(width)
console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.')
console.log(
@@ -342,6 +393,39 @@ for (const fixture of fixtures) {
console.log(
`\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}`
)
const gateFixtures = [
{ label: 'ascii 4KiB fit proof', data: 'x'.repeat(4 * 1024) },
{
label: 'three-byte exact-cap fit proof',
data: '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3)
},
{
label: 'late-wide fit after probes',
data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(11_000)}`
},
{
label: 'late-wide miss during probes',
data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`
},
{ label: 'ascii 64KiB overflow proof', data: 'x'.repeat(64 * 1024) }
]
console.log('\nTerminal frame-fit gate only. Lower is better.')
console.log(
`${pad('fixture', 34)} ${pad('result', 8)} ${pad('whole scan', 12)} ${pad('bounded', 11)} ${pad('speedup', 9)}`
)
for (const fixture of gateFixtures) {
const previous = previousGate(fixture.data)
const bounded = exceedsTerminalStreamChunkBytes(fixture.data)
if (previous !== bounded) {
throw new Error(`gate result differs for ${fixture.label}`)
}
const { previousMs, boundedMs } = measureGateInterleaved(fixture.data)
console.log(
`${pad(fixture.label, 34)} ${pad(bounded ? 'miss' : 'fit', 8)} ${pad(`${(previousMs * 1000).toFixed(2)} us`, 12)} ${pad(`${(boundedMs * 1000).toFixed(2)} us`, 11)} ${pad(boundedMs > 0 ? `${(previousMs / boundedMs).toFixed(2)}x` : 'n/a', 9)}`
)
}
console.log(`gate fixtures=${gateFixtures.length}, checksum=${gateChecksum >>> 0}`)
console.log(
'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.'
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

+3 -2
View File
@@ -243,9 +243,10 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
- **WeChat :** Les groupes 1 et 2 sont complets — vous pouvez rejoindre le troisième.
- **WeChat :** Si le groupe 5 est complet, vous pouvez rejoindre le groupe 6.
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat de la communauté Orca" width="160" />
<img src="../assets/wechat-qr.jpg" alt="QR code WeChat groupe 5 de la communauté Orca" width="160" style="margin-right: 16px;" />
<img src="../assets/wechat-qr-group6.jpg" alt="QR code WeChat groupe 6 de la communauté Orca" width="160" />
- **Feedback &amp; idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
- **Confidentialité :** Voir la [doc confidentialité &amp; télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
+3 -2
View File
@@ -235,9 +235,10 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 其他群已满,现在加入第 5 群。
- **微信:** 如果第 5 群已满,可加入第 6 群。
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信第 5 群二维码" width="160" style="margin-right: 16px;" />
<img src="../assets/wechat-qr-group6.jpg" alt="Orca 社区微信第 6 群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
File diff suppressed because it is too large Load Diff
+10
View File
@@ -31,6 +31,12 @@
results.appendChild(li)
document.title = 'probes:' + results.children.length
}
function markNavigationInvocation(name) {
var marker = document.createElement('meta')
marker.dataset.navigationProbeInvoked = name
marker.content = 'true'
document.head.appendChild(marker)
}
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
var cookieValue = 'x'
@@ -73,6 +79,7 @@
// Navigation probes are opt-in so a failed attempt cannot erase the
// network and bridge evidence before the harness observes it.
document.getElementById('navigate-top').addEventListener('click', function () {
markNavigationInvocation('top-navigation')
try {
window.top.location.href = 'https://example.com/'
setTimeout(function () {
@@ -84,6 +91,7 @@
})
document.getElementById('navigate-self').addEventListener('click', function () {
markNavigationInvocation('self-navigation')
try {
window.location.href = 'https://example.com/self-navigation'
setTimeout(function () {
@@ -95,6 +103,7 @@
})
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
markNavigationInvocation('anchor-form-navigation')
var anchor = document.createElement('a')
anchor.href = 'https://example.com/anchor-navigation'
anchor.textContent = 'Navigation probe'
@@ -111,6 +120,7 @@
})
document.getElementById('navigate-meta').addEventListener('click', function () {
markNavigationInvocation('meta-refresh-navigation')
var refresh = document.createElement('meta')
refresh.httpEquiv = 'refresh'
refresh.content = '0;url=https://example.com/meta-refresh'
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Animated, AppState, Linking, type AppStateStatus } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
Animated,
AppState,
Linking,
type AppStateStatus,
BackHandler,
FlatList,
Image,
@@ -17,6 +19,7 @@ import {
type LayoutChangeEvent,
type ListRenderItem
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
@@ -26,6 +26,8 @@ export type MobileSessionTab =
/** Agent Orca launched in this terminal, if any. This makes chat eligible
* before the first live agent-status update reaches the mobile client. */
launchAgent?: TuiAgent
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
+2 -4
View File
@@ -106,11 +106,9 @@ import {
} from '../../../src/tasks/setup-hook-trust'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../src/tasks/mobile-github-project-group-sort'
import {
type GitHubProjectSortDirection,
type GitHubProjectTable as SharedGitHubProjectTable,
groupRows,
isIterationCurrent,
sortRows,
@@ -10,15 +10,17 @@ import type { RpcClient } from '../transport/rpc-client'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS,
type MobileAiVaultResumeSettings
} from '../session/ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from '../session/ai-vault-resume-preparation'
import { triggerError, triggerSuccess } from '../platform/haptics'
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
import type { Worktree } from '../worktree/workspace-list-types'
@@ -70,9 +70,64 @@ describe('parseMobileMarkdown', () => {
expect(normalizeMobileMarkdownPreviewHtml('Array<string> in prose')).toBe(
'Array<string> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('Promise<Result> in prose')).toBe(
'Promise<Result> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('Promise<Array<string>> in prose')).toBe(
'Promise<Array<string>> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('Map<string, Array<number>> in prose')).toBe(
'Map<string, Array<number>> in prose'
)
expect(normalizeMobileMarkdownPreviewHtml('type Box<T = string> = { value: T }')).toBe(
'type Box<T = string> = { value: T }'
)
expect(
normalizeMobileMarkdownPreviewHtml(
'type Box<T extends object, Value = string> = { value: Value }'
)
).toBe('type Box<T extends object, Value = string> = { value: Value }')
expect(normalizeMobileMarkdownPreviewHtml('a<b=c>')).toBe('a<b=c>')
expect(normalizeMobileMarkdownPreviewHtml('<T> is a type parameter')).toBe(
'<T> is a type parameter'
)
expect(normalizeMobileMarkdownPreviewHtml('<mailto:orca@example.com>')).toBe(
'<mailto:orca@example.com>'
)
expect(normalizeMobileMarkdownPreviewHtml('<ftp://example.com/file>')).toBe(
'<ftp://example.com/file>'
)
expect(normalizeMobileMarkdownPreviewHtml('<tel:+15551234567>')).toBe('<tel:+15551234567>')
expect(normalizeMobileMarkdownPreviewHtml('<https://example.com>')).toBe(
'<https://example.com>'
)
expect(
normalizeMobileMarkdownPreviewHtml(
"<https://en.wikipedia.org/wiki/O'Brien> <svg>hidden</svg>"
)
).toBe("<https://en.wikipedia.org/wiki/O'Brien> hidden")
expect(normalizeMobileMarkdownPreviewHtml('Replace <your-api-key> now')).toBe(
'Replace <your-api-key> now'
)
expect(
normalizeMobileMarkdownPreviewHtml(
'<span title="</your-api-key>"></span> Replace <your-api-key> now'
)
).toBe('Replace <your-api-key> now')
expect(
normalizeMobileMarkdownPreviewHtml('<span title="</string>"></span> Array<string> now')
).toBe('Array<string> now')
expect(
normalizeMobileMarkdownPreviewHtml(
'<span title=</your-api-key>></span> Replace <your-api-key> now'
)
).toBe('Replace <your-api-key> now')
expect(
normalizeMobileMarkdownPreviewHtml('<span title=</string>></span> Array<string> now')
).toBe('Array<string> now')
expect(normalizeMobileMarkdownPreviewHtml('Use <insert name here> next')).toBe(
'Use <insert name here> next'
)
expect(normalizeMobileMarkdownPreviewHtml('<div>Readable text</div>')).toBe('Readable text')
})
@@ -91,4 +146,131 @@ describe('parseMobileMarkdown', () => {
`${literalPlaceholder} and \`Array<string>\``
)
})
it('strips nested HTML and SVG markup without leaking tag variants', () => {
expect(
normalizeMobileMarkdownPreviewHtml(
'<p>Logo<svg viewBox="0 0 1 1"><g><path d="M0 > 0"/></g></svg> done</p>'
)
).toBe('Logo done')
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg/onload=alert(1)><path/></svg> done')).toBe(
'Logo done'
)
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg=invalid><path/></svg> done')).toBe(
'Logo done'
)
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<SVG=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<path=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<p=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<strong=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<math=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<foo=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<my-widget=invalid>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<my-widget disabled>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('<svg=invalid><path/>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('Logo<my-widget=invalid>Hi</my-widget> done')).toBe(
'LogoHi done'
)
expect(normalizeMobileMarkdownPreviewHtml('<SVG><PATH/></SVG>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg>Hi<path>there')).toBe('LogoHithere')
expect(normalizeMobileMarkdownPreviewHtml('Logo<p>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<B>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<strong>Hi')).toBe('LogoHi')
expect(normalizeMobileMarkdownPreviewHtml('Logo<center>Hi <font>there <marquee>now')).toBe(
'LogoHi there now'
)
expect(normalizeMobileMarkdownPreviewHtml('<center>Title</center>')).toBe('Title')
expect(normalizeMobileMarkdownPreviewHtml('<my-widget>Hi</my-widget>')).toBe('Hi')
expect(normalizeMobileMarkdownPreviewHtml('<my:widget>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('<svg:path>')).toBe('')
expect(normalizeMobileMarkdownPreviewHtml('Logo<foo>Hi</foo> done')).toBe('LogoHi done')
expect(normalizeMobileMarkdownPreviewHtml('</1>Logo<svg>Hi</svg> done')).toBe('</1>LogoHi done')
expect(normalizeMobileMarkdownPreviewHtml('Logo<svg>Hi</svg > done')).toBe('LogoHi done')
expect(normalizeMobileMarkdownPreviewHtml('&lt;svg&gt;&lt;path/&gt;&lt;/svg&gt;')).toBe(
'<svg><path/></svg>'
)
expect(normalizeMobileMarkdownPreviewHtml('<p>Use &lt;svg&gt; icons</p>')).toBe(
'Use <svg> icons'
)
expect(normalizeMobileMarkdownPreviewHtml('<p title="a > b">x</p>')).toBe('x')
expect(normalizeMobileMarkdownPreviewHtml("<p title='a > b'>x</p>")).toBe('x')
expect(normalizeMobileMarkdownPreviewHtml('<img alt="a > b">')).toBe('a > b')
expect(
normalizeMobileMarkdownPreviewHtml('<a title="a > b" href="https://example.com">link</a>')
).toBe('[link](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a href="https://example.com">first <a href="https://example.com">second'
)
).toBe('first second')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a-widget href="https://wrong.example">custom</a-widget> <a href="https://example.com">real</a>'
)
).toBe('custom [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a data-href="https://wrong.example" href="https://example.com">real</a>'
)
).toBe('[real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a title="see href=https://wrong.example" href="https://example.com">real</a>'
)
).toBe('[real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml('<a data-href="https://wrong.example">plain</a>')
).toBe('plain')
expect(normalizeMobileMarkdownPreviewHtml('<img data-alt="wrong" alt="right">')).toBe('right')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a:widget href="https://wrong.example">custom</a:widget> <a href="https://example.com">real</a>'
)
).toBe('custom [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a=invalid href="https://wrong.example">bad <a href="https://example.com">real</a>'
)
).toBe('bad [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a href="https://wrong.example"/>bad <a href="https://example.com">real</a>'
)
).toBe('bad [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'<a href="https://wrong.example">bad <a href="https://example.com">real</a>'
)
).toBe('bad [real](https://example.com)')
expect(
normalizeMobileMarkdownPreviewHtml(
'before<p title="unterminated>middle<svg><path/></svg>after'
)
).toBe('beforemiddleafter')
expect(normalizeMobileMarkdownPreviewHtml('before<p title=x<svg><path/></svg>after')).toBe(
'beforeafter'
)
expect(normalizeMobileMarkdownPreviewHtml('<p>first</p><p>second')).toBe('first\nsecond')
expect(normalizeMobileMarkdownPreviewHtml('<strong>first</strong><strong>second')).toBe(
'**first**second'
)
expect(
normalizeMobileMarkdownPreviewHtml(
'<strong-widget>custom</strong-widget> <strong>real</strong>'
)
).toBe('custom **real**')
expect(
normalizeMobileMarkdownPreviewHtml('Logo<strong=invalid>Hi <strong>real</strong> done')
).toBe('LogoHi **real** done')
expect(normalizeMobileMarkdownPreviewHtml('Logo<strong />Hi <strong>real</strong> done')).toBe(
'LogoHi **real** done'
)
expect(normalizeMobileMarkdownPreviewHtml('<strong>bad <strong>real</strong> done')).toBe(
'bad **real** done'
)
expect(normalizeMobileMarkdownPreviewHtml(`${'<p>x'.repeat(4096)}</p>`)).toBe(
`${'x'.repeat(4095)}\nx`
)
})
})
@@ -16,6 +16,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
opencode: require('../../../src/shared/agent-icons/opencode.png'),
'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
ante: require('../../../src/shared/agent-icons/ante.png'),
trae: require('../../../src/shared/agent-icons/trae.png'),
gemini: require('../../../src/shared/agent-icons/gemini.png'),
antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
goose: require('../../../src/shared/agent-icons/goose.png'),
@@ -1,3 +1,11 @@
import {
findMobileMarkdownMarkupTagEnd,
findNextPairedMarkupOpener,
isPairedMarkupOpener,
replaceMobileMarkdownPairedMarkupTags,
stripMobileMarkdownMarkupTags
} from './mobile-markdown-preview-tag-stripper'
// Why: README HTML snippets can document escaped entities; repeated cleanup
// passes must not turn `&amp;lt;` into a real tag and strip it.
const escapedHtmlEntityTokens = [
@@ -5,24 +13,11 @@ const escapedHtmlEntityTokens = [
{ pattern: /&amp;lt;/gi, token: '\uE000ORCA_MD_ENTITY_LT\uE000', value: '&lt;' },
{ pattern: /&amp;gt;/gi, token: '\uE000ORCA_MD_ENTITY_GT\uE000', value: '&gt;' },
{ pattern: /&amp;quot;/gi, token: '\uE000ORCA_MD_ENTITY_QUOT\uE000', value: '&quot;' },
{ pattern: /&amp;#39;/gi, token: '\uE000ORCA_MD_ENTITY_APOS\uE000', value: '&#39;' }
{ pattern: /&amp;#39;/gi, token: '\uE000ORCA_MD_ENTITY_APOS\uE000', value: '&#39;' },
{ pattern: /&lt;/gi, token: '\uE000ORCA_MD_ENTITY_RAW_LT\uE000', value: '<' },
{ pattern: /&gt;/gi, token: '\uE000ORCA_MD_ENTITY_RAW_GT\uE000', value: '>' }
] as const
const strippableHtmlTagNames = new Set(
[
'a abbr address area article aside audio b base bdi bdo blockquote body br button',
'canvas caption cite code col colgroup data datalist dd del details dfn dialog div',
'dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head',
'header hgroup hr html i iframe img input ins kbd label legend li link main map',
'mark menu meta meter nav noscript object ol optgroup option output p picture pre',
'progress q rp rt ruby s samp script search section select slot small source span',
'strong style sub summary sup table tbody td template textarea tfoot th thead time',
'title tr track u ul var video wbr'
]
.join(' ')
.split(' ')
)
function protectEscapedHtmlEntities(value: string): string {
return escapedHtmlEntityTokens.reduce(
(next, entity) => next.replace(entity.pattern, entity.token),
@@ -52,11 +47,7 @@ function decodeHtmlEntities(value: string, preserveEscapedEntities = false): str
function stripTags(value: string): string {
const { protectedText, codeSpans, placeholderPrefix } = protectMarkdownCode(value)
const stripped = decodeHtmlEntities(
protectedText
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\/?([A-Za-z][A-Za-z0-9:-]*)(?:\s[^<>]*)?\/?>/g, (tag, name: string) =>
strippableHtmlTagNames.has(name.toLowerCase()) ? '' : tag
),
stripMobileMarkdownMarkupTags(protectedText.replace(/<!--[\s\S]*?-->/g, '')),
true
)
.replace(/[ \t]+\n/g, '\n')
@@ -67,36 +58,141 @@ function stripTags(value: string): string {
}
function attrValue(tag: string, name: string): string {
const pattern = new RegExp(`${name}\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, 'i')
const match = tag.match(pattern)
const raw = match?.[1] ?? ''
return decodeHtmlEntities(raw.replace(/^["']|["']$/g, ''))
let cursor = 1
while (cursor < tag.length && !/[\s/>]/.test(tag[cursor] ?? '')) {
cursor += 1
}
while (cursor < tag.length) {
while (/\s/.test(tag[cursor] ?? '')) {
cursor += 1
}
if (tag[cursor] === '>' || (tag[cursor] === '/' && tag[cursor + 1] === '>')) {
return ''
}
const attributeStart = cursor
while (!/[\s=/>]/.test(tag[cursor] ?? '>')) {
cursor += 1
}
if (attributeStart === cursor) {
cursor += 1
continue
}
const attributeName = tag.slice(attributeStart, cursor)
while (/\s/.test(tag[cursor] ?? '')) {
cursor += 1
}
if (tag[cursor] !== '=') {
continue
}
cursor += 1
while (/\s/.test(tag[cursor] ?? '')) {
cursor += 1
}
const quote = tag[cursor] === '"' || tag[cursor] === "'" ? tag[cursor] : ''
if (quote) {
cursor += 1
}
const valueStart = cursor
if (quote) {
const valueEnd = tag.indexOf(quote, cursor)
if (valueEnd < 0) {
return ''
}
cursor = valueEnd + 1
if (attributeName.toLowerCase() === name) {
return decodeHtmlEntities(tag.slice(valueStart, valueEnd))
}
continue
}
while (!/[\s>]/.test(tag[cursor] ?? '>')) {
cursor += 1
}
if (attributeName.toLowerCase() === name) {
return decodeHtmlEntities(tag.slice(valueStart, cursor))
}
}
return ''
}
const tagAttributesSource = `(?:[^<>"']|"[^"]*"|'[^']*')*`
const imageTagPattern = new RegExp(`<img\\b${tagAttributesSource}>`, 'gi')
function normalizeAnchorTags(value: string): string {
const lowerValue = value.toLowerCase()
let output = ''
let copyCursor = 0
let searchCursor = 0
let closingStart = -1
while (searchCursor < value.length) {
const start = findNextPairedMarkupOpener(value, lowerValue, 'a', searchCursor)
if (start < 0) {
break
}
const end = findMobileMarkdownMarkupTagEnd(value, start + 2)
if (end < 0) {
if (end === -2) {
break
}
searchCursor = start + 2
continue
}
if (!isPairedMarkupOpener(value, start + 2, end)) {
searchCursor = end + 1
continue
}
const tag = value.slice(start, end + 1)
const href = attrValue(tag, 'href')
if (!href) {
searchCursor = end + 1
continue
}
if (closingStart < end + 1) {
closingStart = lowerValue.indexOf('</a>', end + 1)
}
if (closingStart < 0) {
break
}
const nestedStart = findNextPairedMarkupOpener(value, lowerValue, 'a', end + 1)
if (nestedStart >= 0 && nestedStart < closingStart) {
searchCursor = nestedStart
continue
}
const text = stripTags(value.slice(end + 1, closingStart))
output += value.slice(copyCursor, start)
output += href && text ? `[${text}](${href})` : text
copyCursor = closingStart + 4
searchCursor = copyCursor
}
return output + value.slice(copyCursor)
}
function normalizeInlineHtml(value: string): string {
return value
const imagesNormalized = value
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<img\b[^>]*>/gi, (tag) => attrValue(tag, 'alt') || 'image')
.replace(
/<a\b[^>]*href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)[^>]*>([\s\S]*?)<\/a>/gi,
(tag, _href, label) => {
const href = attrValue(tag, 'href')
const text = stripTags(label)
return href && text ? `[${text}](${href})` : text
}
)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `**${text}**` : ''
})
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `*${text}*` : ''
})
.replace(/<(code|kbd)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `\`${text}\`` : ''
})
.replace(imageTagPattern, (tag) => attrValue(tag, 'alt') || 'image')
let next = normalizeAnchorTags(imagesNormalized)
next = replaceMobileMarkdownPairedMarkupTags(next, ['strong', 'b'], (_name, inner) => {
const text = stripTags(inner)
return text ? `**${text}**` : ''
})
next = replaceMobileMarkdownPairedMarkupTags(next, ['em', 'i'], (_name, inner) => {
const text = stripTags(inner)
return text ? `*${text}*` : ''
})
next = replaceMobileMarkdownPairedMarkupTags(next, ['code', 'kbd'], (_name, inner) => {
const text = stripTags(inner)
return text ? `\`${text}\`` : ''
})
return next
}
// Why: Markdown code is literal source, so it must bypass the HTML strip pass.
@@ -174,15 +270,19 @@ export function normalizeMobileMarkdownPreviewHtml(content: string): string {
// Why: repository Markdown often uses small HTML islands for centered README
// headers and badges. Preview mode should read like Markdown, while Source
// mode remains the exact file bytes.
next = next.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_tag, level, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${'#'.repeat(Number(level))} ${text}\n` : '\n'
})
next = next.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_tag, inner) => {
next = replaceMobileMarkdownPairedMarkupTags(
next,
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
(name, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${'#'.repeat(Number(name.slice(1)))} ${text}\n` : '\n'
}
)
next = replaceMobileMarkdownPairedMarkupTags(next, ['p'], (_name, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${text}\n` : '\n'
})
next = next.replace(/<sub\b[^>]*>([\s\S]*?)<\/sub>/gi, (_tag, inner) =>
next = replaceMobileMarkdownPairedMarkupTags(next, ['sub'], (_name, inner) =>
stripTags(normalizeInlineHtml(inner))
)
next = normalizeInlineHtml(next)
@@ -0,0 +1,324 @@
const knownMarkupTagNames = new Set(
`a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot th thead time title tr track u ul var video wbr fencedframe portal selectedcontent
acronym applet basefont bgsound big blink center command content dir element font frame frameset isindex keygen listing marquee menuitem multicol nextid nobr noembed noframes noindex param plaintext rb rtc shadow spacer strike tt xmp
animate animatemotion animatetransform circle clippath defs desc ellipse feblend fecolormatrix fecomponenttransfer fecomposite feconvolvematrix fediffuselighting fedisplacementmap fedistantlight fedropshadow feflood fefunca fefuncb fefuncg fefuncr fegaussianblur feimage femerge femergenode femorphology feoffset fepointlight fespecularlighting fespotlight fetile feturbulence filter foreignobject g hatch hatchpath image line lineargradient marker mask metadata mpath path pattern polygon polyline radialgradient rect discard set stop svg switch symbol text textpath tspan use view
altglyph altglyphdef altglyphitem animatecolor cursor font-face font-face-format font-face-name font-face-src font-face-uri glyph glyphref hkern missing-glyph solidcolor vkern
annotation annotation-xml maction math menclose merror mfenced mfrac mi mmultiscripts mn mo mover mpadded mphantom mprescripts mroot mrow ms mspace msqrt mstyle msub msubsup msup mtable mtd mtext mtr munder munderover semantics`.split(
/\s+/
)
)
function followsAttributeEquals(value: string, index: number): boolean {
let previous = index - 1
while (previous >= 0 && /\s/.test(value[previous] ?? '')) {
previous -= 1
}
return value[previous] === '='
}
export function findMobileMarkdownMarkupTagEnd(value: string, start: number): number {
let quote = ''
for (let index = start; index < value.length; index += 1) {
const char = value[index] ?? ''
if (quote) {
if (char === quote) {
quote = ''
}
continue
}
if ((char === '"' || char === "'") && followsAttributeEquals(value, index)) {
quote = char
} else if (char === '<') {
return -1
} else if (char === '>') {
return index
}
}
if (quote) {
for (let index = start; index < value.length; index += 1) {
const char = value[index] ?? ''
if (char === '<') {
return -1
}
if (char === '>') {
return index
}
}
}
return -2
}
function nextNestedMarkupCursor(value: string, start: number): number {
const nestedStart = value.indexOf('<', start + 1)
if (!followsAttributeEquals(value, nestedStart)) {
return nestedStart
}
const nestedEnd = findMobileMarkdownMarkupTagEnd(value, nestedStart + 1)
if (nestedEnd < 0) {
return nestedStart
}
return nestedEnd + 1 + Number(value[nestedEnd + 1] === '>')
}
function isMarkupNameStart(char: string): boolean {
const code = char.charCodeAt(0)
return (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
}
const markupNameCharPattern = /^[A-Za-z0-9:-]$/
function isMarkupNameChar(char: string): boolean {
return markupNameCharPattern.test(char)
}
function isPairedMarkupOpenerBoundary(char: string): boolean {
return char === '>' || /\s/.test(char)
}
export function isPairedMarkupOpener(value: string, nameEnd: number, end: number): boolean {
if (!isPairedMarkupOpenerBoundary(value[nameEnd] ?? '')) {
return false
}
let lastContent = end - 1
while (lastContent >= nameEnd && /\s/.test(value[lastContent] ?? '')) {
lastContent -= 1
}
return value[lastContent] !== '/'
}
export function findNextPairedMarkupOpener(
value: string,
lowerValue: string,
name: string,
cursor: number
): number {
let start = lowerValue.indexOf(`<${name}`, cursor)
while (start >= 0) {
const nameEnd = start + name.length + 1
const end = findMobileMarkdownMarkupTagEnd(value, nameEnd)
if (end >= 0 && isPairedMarkupOpener(value, nameEnd, end)) {
return start
}
start = lowerValue.indexOf(`<${name}`, start + name.length + 1)
}
return -1
}
export function replaceMobileMarkdownPairedMarkupTags(
value: string,
tagNames: readonly string[],
replacement: (name: string, inner: string) => string
): string {
const lowerValue = value.toLowerCase()
const activeNames = new Set(tagNames)
const nextStarts = new Map<string, number>()
const nextClosingStarts = new Map<string, number>()
let output = ''
let copyCursor = 0
let searchCursor = 0
while (activeNames.size > 0 && searchCursor < value.length) {
let name = ''
let start = -1
for (const candidate of activeNames) {
let candidateStart = nextStarts.get(candidate) ?? -1
if (candidateStart < searchCursor) {
candidateStart = findNextPairedMarkupOpener(value, lowerValue, candidate, searchCursor)
if (candidateStart < 0) {
activeNames.delete(candidate)
nextStarts.delete(candidate)
continue
}
nextStarts.set(candidate, candidateStart)
}
if (start < 0 || candidateStart < start) {
name = candidate
start = candidateStart
}
}
if (start < 0) {
break
}
const nameEnd = start + name.length + 1
nextStarts.delete(name)
const end = findMobileMarkdownMarkupTagEnd(value, nameEnd)
const closingTag = `</${name}>`
let closingStart = nextClosingStarts.get(name) ?? -1
if (closingStart < end + 1) {
closingStart = lowerValue.indexOf(closingTag, end + 1)
nextClosingStarts.set(name, closingStart)
}
if (closingStart < 0) {
// No later opener of this name can match once its final closer is behind us.
activeNames.delete(name)
searchCursor = start + 1
continue
}
const nestedStart = findNextPairedMarkupOpener(value, lowerValue, name, end + 1)
if (nestedStart >= 0 && nestedStart < closingStart) {
nextStarts.set(name, nestedStart)
searchCursor = nestedStart
continue
}
output += value.slice(copyCursor, start)
output += replacement(name, value.slice(end + 1, closingStart))
copyCursor = closingStart + closingTag.length
searchCursor = copyCursor
}
return output + value.slice(copyCursor)
}
function isPlaceholderSuffix(value: string, start: number, end: number): boolean {
let lastNonSpace = ''
for (let index = start; index < end; index += 1) {
const char = value[index] ?? ''
if (char === '=') {
return false
}
if (!/\s/.test(char)) {
lastNonSpace = char
}
}
return lastNonSpace !== '/'
}
function closingMarkupTagNames(value: string): Set<string> {
const names = new Set<string>()
let cursor = 0
while (cursor < value.length) {
const start = value.indexOf('<', cursor)
if (start < 0) {
return names
}
const end = findMobileMarkdownMarkupTagEnd(value, start + 1)
if (end < 0) {
if (end === -2) {
return names
}
cursor = nextNestedMarkupCursor(value, start)
continue
}
if (value[start + 1] !== '/' || !isMarkupNameStart(value[start + 2] ?? '')) {
cursor = end + 1
continue
}
let nameEnd = start + 3
while (isMarkupNameChar(value[nameEnd] ?? '')) {
nameEnd += 1
}
let boundary = nameEnd
while (/\s/.test(value[boundary] ?? '')) {
boundary += 1
}
if (boundary === end) {
names.add(value.slice(start + 2, nameEnd).toLowerCase())
}
cursor = end + 1
}
return names
}
export function stripMobileMarkdownMarkupTags(value: string): string {
let output = ''
let cursor = 0
const closingTagNames = closingMarkupTagNames(value)
while (cursor < value.length) {
const start = value.indexOf('<', cursor)
if (start < 0) {
return output + value.slice(cursor)
}
output += value.slice(cursor, start)
const isClosing = value[start + 1] === '/'
const nameStart = start + (isClosing ? 2 : 1)
if (!isMarkupNameStart(value[nameStart] ?? '')) {
output += '<'
cursor = start + 1
continue
}
let nameEnd = nameStart + 1
while (isMarkupNameChar(value[nameEnd] ?? '')) {
nameEnd += 1
}
const previousChar = value[start - 1]
const name = value.slice(nameStart, nameEnd)
const lowerName = name.toLowerCase()
const end = findMobileMarkdownMarkupTagEnd(value, nameEnd)
if (end < 0) {
if (end === -2) {
return output + value.slice(start)
}
const nestedStart = value.indexOf('<', nameEnd)
const isNestedGeneric =
Boolean(previousChar && /\w/.test(previousChar)) &&
!isClosing &&
!/[-:]/.test(name) &&
!knownMarkupTagNames.has(lowerName) &&
!closingTagNames.has(lowerName)
if (isNestedGeneric && nestedStart >= 0) {
output += value.slice(start, nestedStart)
cursor = nestedStart
continue
}
cursor = nextNestedMarkupCursor(value, start)
continue
}
const suffixStart = value[nameEnd] ?? ''
const isKnownMarkup = knownMarkupTagNames.has(lowerName)
const canPreserveOpening = !isClosing && !closingTagNames.has(lowerName)
const isAutolink =
/^<[A-Za-z][A-Za-z0-9+.-]+:[^\s<>]*>$/.test(value.slice(start, end + 1)) &&
nameEnd < end &&
canPreserveOpening
const isComparisonAngleText =
name.length === 1 &&
suffixStart === '=' &&
Boolean(previousChar && /\w/.test(previousChar)) &&
!/\w/.test(value[start - 2] ?? '') &&
canPreserveOpening
const isGeneric =
Boolean(previousChar && /\w/.test(previousChar)) &&
canPreserveOpening &&
nameEnd === end &&
!/[-:]/.test(name) &&
!isKnownMarkup
const isTypeParameter =
canPreserveOpening && nameEnd === end && /^[A-Z]$/.test(name) && !isKnownMarkup
const tagSuffix = value.slice(nameEnd, end)
const hasGenericDefault =
/^\s*=/.test(tagSuffix) || /^\s*(?:extends\b|,)[\s\S]*=/.test(tagSuffix)
const isGenericDefault =
Boolean(previousChar && /\w/.test(previousChar)) &&
canPreserveOpening &&
/^[A-Z][A-Za-z0-9]*$/.test(name) &&
!isKnownMarkup &&
hasGenericDefault &&
!/\/\s*$/.test(tagSuffix)
const isUnpairedPlaceholder =
canPreserveOpening &&
!isKnownMarkup &&
isPlaceholderSuffix(value, nameEnd, end) &&
(!name.includes('-') || tagSuffix.trim().length === 0) &&
!name.includes(':')
if (
isAutolink ||
isComparisonAngleText ||
isGeneric ||
isTypeParameter ||
isGenericDefault ||
isUnpairedPlaceholder
) {
output += value.slice(start, end + 1)
}
cursor = end + 1
}
return output
}
@@ -8,9 +8,9 @@ import {
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
+1 -41
View File
@@ -4,10 +4,7 @@ import {
buildAiVaultResumeShellCommand,
realHomeCodexResumeEnvDeletion
} from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
@@ -167,43 +164,6 @@ function normalizeMobileAiVaultResumeCommandOverrides(
return normalized
}
// Why: without an explicit timeout, a socket drop mid-resume parks the request
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as { useRealCodexHome?: unknown } | null
if (result?.useRealCodexHome !== true) {
return session
}
return { ...session, codexHome: null }
}
export async function resumeAiVaultSessionInTerminal(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
@@ -2,12 +2,15 @@ import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from './ai-vault-resume-preparation'
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
const PER_ACCOUNT_HOME = '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
@@ -121,10 +124,11 @@ describe('prepareMobileAiVaultSessionResume', () => {
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
{
agent: 'codex' as const,
codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId']
},
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => {
])('does not prepare unrepinnable session $agent at $codexHome', async (overrides) => {
const current = legacySession(overrides)
const sendRequest = vi.fn()
@@ -132,6 +136,51 @@ describe('prepareMobileAiVaultSessionResume', () => {
expect(sendRequest).not.toHaveBeenCalled()
})
it('repins a per-account session to the home the host substitutes', async () => {
const substituteHome = '/Users/ada/Library/Application Support/orca/codex-accounts/b/home'
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false, substituteCodexHome: substituteHome }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, current)
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
expect(sendRequest).toHaveBeenCalledWith(
'aiVault.prepareSessionResume',
{
agent: 'codex',
filePath: current.filePath,
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'local'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(prepared.codexHome).toBe(substituteHome)
expect(launch.command).toContain(`CODEX_HOME='${substituteHome}'`)
})
it('keeps a per-account session home when an older host sends no repin', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it('keeps a per-account session usable when the host cannot prepare at all', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it.each([
{
code: 'internal_error',
@@ -0,0 +1,60 @@
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome,
isPerAccountManagedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host'
import type { RpcClient } from '../transport/rpc-client'
// Why: without an explicit timeout, a socket drop mid-resume parks the request
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
// Why: per-account repinning runs on the serving host, whose account
// selection only applies to that host's own ("local") sessions.
const needsAccountRepin =
isPerAccountManagedCodexHome(session.codexHome) &&
(!session.executionHostId || session.executionHostId === LOCAL_EXECUTION_HOST_ID)
if (
session.agent !== 'codex' ||
(!isLegacySharedCodexHome(session.codexHome) && !needsAccountRepin)
) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as {
useRealCodexHome?: unknown
substituteCodexHome?: unknown
} | null
if (result?.useRealCodexHome === true) {
return { ...session, codexHome: null }
}
// Why: older hosts never send a repin home, so absence keeps the session's own home.
if (typeof result?.substituteCodexHome === 'string' && result.substituteCodexHome) {
return { ...session, codexHome: result.substituteCodexHome }
}
return session
}
@@ -25,6 +25,8 @@ export type MobileNativeChatTab = {
type: string
launchAgent?: string | null
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -169,6 +169,52 @@ describe('sendMobileNativeChatMessage', () => {
).resolves.toBe('rejected')
})
it('prepends the input-line clear byte when clearInputFirst is set', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(client.sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'term',
text: '\x15hello',
enter: true
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
)
})
it('sends the text verbatim when clearInputFirst is not set', async () => {
// An image send pastes the image (behind its own leading Ctrl+U) before this
// text write; a clear byte here would kill the pasted image off the input line.
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'what is this',
clearInputFirst: false
})
const sent = vi.mocked(client.sendRequest).mock.calls[0]?.[1] as { text: string }
expect(sent.text).toBe('what is this')
expect(sent.text.startsWith('\x15')).toBe(false)
})
it('sends a single non-submitting Escape for prompt cancellation', async () => {
const client = clientWithResponse({
id: 'request',
@@ -8,11 +8,17 @@ type MobileTerminalClient = {
type: 'mobile'
}
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
// same byte before its body), so a launch-context prefill parked there cannot
// concatenate with a mobile chat message. The host writes text bytes verbatim.
const CLEAR_UNSUBMITTED_INPUT = '\x15'
type MobileNativeChatSendArgs = {
client: RpcClient
terminal: string
text: string
enter?: boolean
clearInputFirst?: boolean
mobileClient?: MobileTerminalClient
/** Shared budget for a whole user action (heal → paste → text, or one selector's
* keystroke sequence). Omit to give this write its own full budget. */
@@ -51,7 +57,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
'terminal.send',
{
terminal: args.terminal,
text: args.text,
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
enter: args.enter ?? true,
...(args.mobileClient ? { client: args.mobileClient } : {})
},
@@ -87,6 +87,29 @@ describe('mobile terminal records', () => {
).toEqual([])
})
it('treats a launch draft appearing or retracting as a session-tab change', () => {
// The route keeps `prev` when these compare equal, so a frame whose only
// delta is the draft would never reach the chat composer.
const base: MobileTerminalSessionTab = {
type: 'terminal',
id: 'term-1::leaf-1',
parentTabId: 'term-1',
leafId: 'leaf-1',
title: 'Claude',
status: 'ready',
terminal: 'pty-1',
isActive: true
}
const seeded: MobileTerminalSessionTab = {
...base,
launchDraft: 'https://github.com/o/r/issues/12'
}
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
})
it('treats terminal agent-status changes as session-tab changes', () => {
const base: MobileTerminalSessionTab = {
type: 'terminal',
@@ -17,6 +17,8 @@ export type MobileTerminalSessionTab = {
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -84,6 +86,9 @@ function mobileSessionTabEqual(
a.leafId === b.leafId &&
a.status === b.status &&
a.terminal === b.terminal &&
// A frame whose only delta is the launch draft appearing or retracting
// still has to reach the chat composer.
a.launchDraft === b.launchDraft &&
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
@@ -10,25 +10,37 @@ const clearDraftForSend = vi.fn()
const restoreRejectedDraft = vi.fn()
const holdUnconfirmedSend = vi.fn()
// Mutable stand-ins so the launch-draft wiring below can drive chat resolution
// and transcript state; defaults keep the send-seam tests unchanged.
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const draftsArgs: Record<string, unknown>[] = []
// The controller composes many session hooks; each is mocked to a minimal shape
// so this test isolates the send seam (outcome -> drafts accounting).
vi.mock('./use-mobile-session-view-mode', () => ({
useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() })
useMobileSessionViewMode: () => ({
isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId),
toggleTabChatView: vi.fn()
})
}))
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => ({ messages: [] })
useMobileNativeChatSession: () => sessionState
}))
vi.mock('./use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: () => ({
composerText: '',
setComposerText: vi.fn(),
pending: [],
captureSendOrigin,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
holdUnconfirmedSend
})
useMobileNativeChatDrafts: (args: Record<string, unknown>) => {
draftsArgs.push(args)
return {
composerText: '',
setComposerText: vi.fn(),
pending: [],
captureSendOrigin,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
holdUnconfirmedSend
}
}
}))
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
@@ -213,6 +225,27 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('pre-clears the input line for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
// agent receives text alone while the echo bubble still shows the thumbnail.
sendWithOutcome.mockResolvedValue('accepted')
await act(async () => {
await controller!.handleNativeChatSend('answer')
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'answer', clearInputFirst: true })
)
await act(async () => {
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({ text: 'look', clearInputFirst: false })
)
})
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
sendWithOutcome.mockResolvedValue('unknown')
let accepted = false
@@ -282,3 +315,105 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(onSendError).toHaveBeenCalledWith('Message not sent')
})
})
describe('useMobileNativeChatController launch-draft wiring', () => {
let renderer: ReactTestRenderer | null = null
const clientStub = { sendRequest: vi.fn() }
const chatTab = {
type: 'terminal',
id: 'tab-1',
title: 'Claude',
terminal: 'term-1',
launchAgent: 'claude',
launchDraft: 'https://github.com/o/r/issues/12',
isActive: true
}
function Harness({ tab }: { tab: unknown }): null {
useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: tab as never,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
function render(tab: unknown): void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(Harness, { tab }))
})
} finally {
spy.mockRestore()
}
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
draftsArgs.length = 0
viewMode.isTabChatView = () => true
sessionState.messages = []
sessionState.status = 'ready'
sessionState.transcriptLoading = false
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('forwards the tab launch draft and chat-active flag for a chat-resolved tab', () => {
render(chatTab)
expect(draftsArgs.at(-1)).toMatchObject({
tabId: 'tab-1',
launchDraft: 'https://github.com/o/r/issues/12',
chatActive: true,
transcriptLoading: false
})
})
it('forwards the raw draft with chatActive false when the tab shows the terminal', () => {
// Nulling the draft off-chat is indistinguishable from a host retraction and
// permanently declines the prefill; the flag is what keeps them apart.
viewMode.isTabChatView = () => false
render(chatTab)
expect(draftsArgs.at(-1)).toMatchObject({
launchDraft: 'https://github.com/o/r/issues/12',
chatActive: false
})
})
it('forwards the session hook’s transcriptLoading, not its status', () => {
// 'working' masks 'loading' in status, so only the read-phase signal is honest.
sessionState.status = 'working'
sessionState.transcriptLoading = true
render(chatTab)
expect(draftsArgs.at(-1)).toMatchObject({ transcriptLoading: true })
})
it('forwards a null draft for a tab that publishes none', () => {
render({ ...chatTab, launchDraft: undefined })
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
})
})
@@ -143,7 +143,13 @@ export function useMobileNativeChatController(args: {
worktreeId,
tabId: activeSessionTabId,
sessionId: activeChatSessionId,
messages: nativeChatSession.messages
messages: nativeChatSession.messages,
launchDraft: activeSessionTab?.launchDraft ?? null,
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
// a null is indistinguishable from a host retraction, and peeking at the
// terminal view would permanently decline the prefill.
chatActive: showNativeChat,
transcriptLoading: nativeChatSession.transcriptLoading
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
@@ -0,0 +1,310 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
function userTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
// Adoption and retirement of the host-published launch draft (the TUI-input
// prefill mirrored into the chat composer). Split from the send/pending suite
// so both stay under the per-file line cap.
describe('useMobileNativeChatDrafts launch draft', () => {
let renderer: ReactTestRenderer | null = null
let state: DraftState | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = [],
launchDraft = null,
chatActive = true,
transcriptLoading = false
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages,
launchDraft,
chatActive,
transcriptLoading
})
return null
}
async function mount(tabId: string): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { tabId }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('prefills the composer from a host launch draft exactly once', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
)
)
expect(state?.composerText).toBe('https://github.com/o/r/issues/12')
// A user clear must not see the prefill resurrected on the next render.
act(() => state?.setComposerText(''))
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'https://github.com/o/r/issues/12' })
)
)
expect(state?.composerText).toBe('')
})
it('does not overwrite typed composer text with a launch draft', async () => {
await mount('a')
act(() => state?.setComposerText('typed first'))
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('typed first')
})
it('declines a launch draft when the transcript already has a user turn', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('m1', 'already sent')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('')
})
it('clears an untouched prefill once a user turn lands, keeping user edits', async () => {
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('m1', 'sent from the TUI')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('')
// Edited prefill survives resolution on another tab's copy.
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
act(() => state?.setComposerText('issue link plus my notes'))
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: [userTextMessage('m2', 'sent from the TUI')],
launchDraft: 'issue link'
})
)
)
expect(state?.composerText).toBe('issue link plus my notes')
})
it('holds the launch draft until the transcript read settles', async () => {
// `session.tabs` carries launchDraft before the transcript loads. Seeding on
// the empty in-flight list would prefill an already-submitted issue link, and
// a send tapped before it retracts duplicates it to the agent.
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
)
)
expect(state?.composerText).toBe('')
// The settled transcript already holds the submitted turn — decline for good.
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
messages: [userTextMessage('m1', 'already sent from the TUI')]
})
)
)
expect(state?.composerText).toBe('')
})
it('seeds once the transcript settles empty', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', transcriptLoading: true })
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('holds the seed while the tab is not resolved to chat view', async () => {
// Off chat the session hook is not subscribed, so `messages` is empty for a
// reason that says nothing about the transcript — judging the seed from it
// would prefill an issue link the agent already submitted in the TUI.
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', launchDraft: 'issue link', chatActive: false })
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
messages: [userTextMessage('m1', 'already sent from the TUI')]
})
)
)
expect(state?.composerText).toBe('')
})
it('keeps an adopted prefill when the tab momentarily drops out of the snapshot', async () => {
// A session-tabs frame can transiently omit the active tab: the draft then
// reads as retracted and chat as inactive, but nothing was resolved.
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null, chatActive: false }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('does not decline another tab’s prefill from the transcript it is still showing', async () => {
// The session hook resets its list in an effect, so the commit that first
// sees tab b still carries tab a's turns. Only transcriptLoading says so.
const carriedOver = [userTextMessage('m1', 'sent on a')]
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: carriedOver,
launchDraft: 'issue link',
transcriptLoading: true
})
)
)
expect(state?.composerText).toBe('')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('does not retire an adopted prefill from the transcript it is still showing', async () => {
const carriedOver = [userTextMessage('m1', 'sent on a')]
await mount('b')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', messages: carriedOver }))
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'b',
messages: carriedOver,
launchDraft: 'issue link',
transcriptLoading: true
})
)
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'b', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
})
it('clears an untouched prefill when the host stops publishing the launch draft', async () => {
await mount('a')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: 'issue link' }))
)
expect(state?.composerText).toBe('issue link')
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', launchDraft: null }))
)
expect(state?.composerText).toBe('')
})
})
@@ -43,18 +43,27 @@ describe('useMobileNativeChatDrafts', () => {
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = []
messages = [],
launchDraft = null,
chatActive = true,
transcriptLoading = false
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages
messages,
launchDraft,
chatActive,
transcriptLoading
})
return null
}
@@ -41,6 +41,15 @@ export function useMobileNativeChatDrafts(args: {
tabId: string | null
sessionId: string | null
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
/** Whether the tab is currently resolved to the chat view. Off-chat the
* launch-draft effects hold their state instead of acting on it. */
chatActive?: boolean
/** `messages` is not yet this session's real history (read in flight, or the
* transcript still belongs to the previously active tab), so it cannot be
* trusted to decline or retire the seed. */
transcriptLoading?: boolean
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
@@ -57,7 +66,16 @@ export function useMobileNativeChatDrafts(args: {
onUnconfirmed: () => void
) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages } = args
const {
hostId,
worktreeId,
tabId,
sessionId,
messages,
launchDraft,
chatActive = true,
transcriptLoading
} = args
const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
const [drafts, setDrafts] = useState<Record<string, string>>({})
@@ -73,6 +91,63 @@ export function useMobileNativeChatDrafts(args: {
activePendingKeyRef.current = pendingKey
const mountedRef = useRef(false)
// Seeded launch-context text per tab; '' marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
// adopt it once as the composer draft so mobile shows the same context.
useEffect(() => {
if (
!draftKey ||
!chatActive ||
!launchDraft?.trim() ||
seededLaunchDraftByKeyRef.current.has(draftKey)
) {
return
}
// Why: `session.tabs` carries launchDraft before the transcript read settles,
// and an empty (or previous tab's) list would let the decline below misjudge
// an already-submitted prefill — long enough for a send to duplicate it.
if (transcriptLoading) {
return
}
// A user turn already in the transcript means the one-line TUI prefill was
// submitted or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, '')
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
// it (desktop sent or reconciled it). User edits are always kept.
useEffect(() => {
// Same gates as the seed: off-chat there is no retraction to read (the tab
// publishes no draft to us), and an untrusted transcript would wipe an
// untouched copy on the strength of another tab's user turns.
if (!draftKey || !chatActive || transcriptLoading) {
return
}
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
if (!seeded) {
return
}
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, '')
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
if (!draftKey) {
@@ -99,6 +99,13 @@ export function useMobileNativeChatMessageSend(args: {
client,
terminal: handle,
text,
// Why: pre-clear only when nothing was deliberately pasted first. The heal
// above fires only for terminals a mobile image paste marked, so a desktop
// launch-draft prefill parked on the input line would otherwise glue onto
// this message. An image send already led its own paste with Ctrl+U, and a
// second one here would wipe the image it just pasted (desktop's image path
// likewise clears once, before the paste, and never again).
clearInputFirst: !images?.length,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -218,3 +218,143 @@ describe('useMobileNativeChatSession', () => {
expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail'])
})
})
describe('useMobileNativeChatSession transcriptLoading', () => {
let renderer: ReactTestRenderer | null = null
const renders: {
sessionId: string | null
transcriptLoading: boolean
status: string
ids: string[]
}[] = []
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
renders.length = 0
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function Harness({
client,
sessionId,
agent = 'claude'
}: {
client: RpcClient | null
sessionId: string | null
agent?: string | null
}): null {
const session = useMobileNativeChatSession({
client,
agent,
sessionId,
transcriptPath: null
})
renders.push({
sessionId,
transcriptLoading: session.transcriptLoading,
status: session.status,
ids: session.messages.map((entry) => entry.id)
})
return null
}
async function mountAt(client: RpcClient | null, sessionId: string | null): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { client, sessionId }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('reports loading on the very first render, before the subscription effect runs', async () => {
// `status` starts at 'idle', so on its own it would tell the launch-draft
// seed that an empty transcript is this session's real history.
const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {})
await mountAt({ subscribe } as unknown as RpcClient, 'session-a')
expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] })
})
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
// Leaving chat view nulls the agent, then returning restores the identity a
// settled read already matched — but its list was cleared, so trusting it
// would report 'ready' over an empty transcript.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
const client = { subscribe } as unknown as RpcClient
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false })
// Toggle out to the terminal view, then back.
await act(async () =>
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null }))
)
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
})
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
// A reconnect swaps the client without moving the identity; the effect
// re-subscribes and clears the list, so the old outcome must not stand.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
const client = { subscribe } as unknown as RpcClient
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
})
it('never hands out the previous session’s messages under the new session id', async () => {
const subscribe: RpcClient['subscribe'] = vi.fn((_method, params, onData) => {
if ((params as { sessionId: string }).sessionId === 'session-a') {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
}
return () => {}
})
const client = { subscribe } as unknown as RpcClient
await mountAt(client, 'session-a')
await act(async () =>
renderer?.update(createElement(Harness, { client, sessionId: 'session-b' }))
)
// The effect that resets the list lands a commit later, so `messages` still
// holds session-a's transcript here — it must never surface under b, and b
// must read as loading until its own read settles.
const leaked = renders.find(
(entry) => entry.sessionId === 'session-b' && entry.ids.includes('a-1')
)
expect(leaked).toBeUndefined()
expect(renders.find((entry) => entry.sessionId === 'session-b')).toMatchObject({
transcriptLoading: true,
ids: []
})
})
})
@@ -13,6 +13,12 @@ export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'r
export type MobileNativeChatSession = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
/** True while `messages` cannot be trusted as this session's real history:
* the read is in flight, OR the subscription effect has not yet caught up to
* a just-changed agent/session, so `messages`/`status` still describe the
* previous tab. Consumers that decide something from an empty transcript
* (the launch-draft seed) must wait for this to clear. */
transcriptLoading: boolean
error?: string
/** True when an older page may exist (the last read filled the window). */
hasMore: boolean
@@ -22,6 +28,9 @@ export type MobileNativeChatSession = {
loadEarlier: () => void
}
// Stable empty reference so a not-yet-current read doesn't churn consumers.
const EMPTY_MESSAGES: NativeChatMessage[] = []
// Small first page for a fast first paint; grows by a page as the user scrolls.
const INITIAL_LIMIT = 40
const PAGE = 60
@@ -42,7 +51,33 @@ export function useMobileNativeChatSession(args: {
}): MobileNativeChatSession {
const { client, agent, sessionId, transcriptPath } = args
const [messages, setMessages] = useState<NativeChatMessage[]>([])
const [status, setStatus] = useState<MobileNativeChatStatus>('idle')
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
// Pre-read status is a pure function of the props, so derive it rather than
// letting the effect write it a commit later.
const initialStatus: MobileNativeChatStatus =
!client || !agent ? 'idle' : !sessionId ? 'waiting-session' : 'loading'
// Only the settled outcome is genuinely async, and it is tagged with the
// identity it describes so a just-switched tab is never judged by the
// previous tab's transcript — the effect that clears `messages` is passive
// and lands a commit late.
const [read, setRead] = useState<{
client: RpcClient
identity: string
status: MobileNativeChatStatus
} | null>(null)
// Drop it the moment its subscription stops being the live one — identity and
// client are the effect's only inputs, so together they catch every re-run.
// Without this a toggle out of chat view and back (agent null, then the same
// identity again) would resurface a settled 'ready' over an emptied list.
let current = read
if (current !== null && (current.identity !== identity || current.client !== client)) {
current = null
setRead(null)
}
// A settled read only counts while the props still call for one: losing the
// client/agent/session means idle or waiting-session outranks it outright.
const settled = initialStatus === 'loading' ? current : null
const status = settled ? settled.status : initialStatus
const [error, setError] = useState<string | undefined>(undefined)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
@@ -78,16 +113,12 @@ export function useMobileNativeChatSession(args: {
setHasMore(false)
beforeOffsetRef.current = null
if (!client || !agent) {
setStatus('idle')
return
}
if (!sessionId) {
setStatus('waiting-session')
return
}
setStatus('loading')
const unsubscribe = client.subscribe(
'nativeChat.subscribe',
{
@@ -121,7 +152,7 @@ export function useMobileNativeChatSession(args: {
return
}
if (applied.kind === 'error') {
setStatus('error')
setRead({ client, identity, status: 'error' })
setError(applied.error)
return
}
@@ -140,7 +171,7 @@ export function useMobileNativeChatSession(args: {
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setStatus('ready')
setRead({ client, identity, status: 'ready' })
}
)
@@ -148,7 +179,7 @@ export function useMobileNativeChatSession(args: {
cancelled = true
unsubscribe()
}
}, [client, agent, sessionId, transcriptPath, setList])
}, [client, agent, sessionId, transcriptPath, identity, setList])
const loadEarlier = useCallback(() => {
if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) {
@@ -215,5 +246,15 @@ export function useMobileNativeChatSession(args: {
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
return { messages, status, error, hasMore, loadingEarlier, loadEarlier }
return {
// Withheld until the settled read belongs to this identity: the effect that
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: settled ? messages : EMPTY_MESSAGES,
status,
transcriptLoading: status === 'loading',
error,
hasMore,
loadingEarlier,
loadEarlier
}
}
@@ -1,6 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcSuccess } from '../transport/types'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides'
+3
View File
@@ -13,6 +13,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'opencode',
'mimo-code',
'ante',
'trae',
'pi',
'omp',
'gemini',
@@ -50,6 +51,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
opencode: 'OpenCode',
'mimo-code': 'MiMo Code',
ante: 'Ante',
trae: 'Trae',
pi: 'Pi',
omp: 'OMP',
gemini: 'Gemini',
@@ -84,6 +86,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
opencode: 'opencode.ai',
'mimo-code': 'mimo.xiaomi.com',
ante: 'antigma.ai',
trae: 'www.trae.cn',
omp: 'omp.sh',
gemini: 'gemini.google.com',
antigravity: 'antigravity.google',
+5 -7
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.160-rc.2",
"version": "1.4.160-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -11,10 +11,10 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality.json --report-unused-disable-directives-severity warn src config tests mobile",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests mobile",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
"audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none",
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
@@ -22,12 +22,9 @@
"doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
"check:quadratic-buffer-concat": "node config/scripts/check-quadratic-buffer-concat.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
@@ -195,6 +192,7 @@
"electron-builder-squirrel-windows": "^26.15.3",
"electron-vite": "^5.0.0",
"emoji-picker-react": "^4.19.1",
"emojibase-data": "17.0.0",
"happy-dom": "^20.9.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
+18
View File
@@ -252,6 +252,9 @@ importers:
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.7)
emojibase-data:
specifier: 17.0.0
version: 17.0.0(emojibase@17.0.0)
happy-dom:
specifier: ^20.9.0
version: 20.9.0
@@ -3986,6 +3989,15 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emojibase-data@17.0.0:
resolution: {integrity: sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==}
peerDependencies:
emojibase: '*'
emojibase@17.0.0:
resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==}
engines: {node: '>=18.12.0'}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
@@ -10031,6 +10043,12 @@ snapshots:
emoji-regex@8.0.0: {}
emojibase-data@17.0.0(emojibase@17.0.0):
dependencies:
emojibase: 17.0.0
emojibase@17.0.0: {}
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
+12 -5
View File
@@ -60,22 +60,29 @@ If a command returns `orchestration_migration_required`, `run_required`, or a li
1. Confirm `effectsApplied` is `false`.
2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.
3. Read the guide completely. Do not retry the rejected command unchanged.
4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`.
5. Inspect any pre-upgrade terminal before creating replacement work.
4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement.
5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point.
6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement.
The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.
Pre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection:
The cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects.
Legacy database rows and terminal output remain available for explicit read-only inspection:
```bash
orca orchestration run-list --json
orca orchestration run-show --id run_legacy_local --json
orca orchestration task-list --run run_legacy_local --json
orca orchestration inbox --full --json
orca orchestration check --terminal <legacy_handle> --peek --json
orca orchestration check --terminal <legacy_handle> --peek --format --json
orca terminal read --terminal <legacy_handle> --json
orca terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
```
Read-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run.
Read-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands.
Never launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run.
## Ownership
+4 -23
View File
@@ -1,5 +1,9 @@
import { RuntimeClientError } from './runtime/types'
import { unknownCommandData, unknownFlagData } from './command-suggestion'
import { specPaths, type CommandSpec } from './command-spec'
export { specPaths }
export type { CommandSpec }
export type ParsedArgs = {
commandPath: string[]
@@ -7,23 +11,6 @@ export type ParsedArgs = {
positionalFlagConflicts?: string[]
}
export type CommandSpec = {
path: string[]
// Why: conventional alternate verbs should resolve without duplicating specs
// or handler registrations.
aliases?: string[][]
argumentMode?: 'parsed' | 'passthrough'
// Why: irreversibly destroys persistent state — typo recovery must not steer a
// benign mistake into one of these via the agent nextSteps channel. #6303
destructive?: boolean
summary: string
usage: string
allowedFlags: string[]
positionalArgs?: string[]
examples?: string[]
notes?: string[]
}
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment'])
export const BOOLEAN_FLAGS = new Set([
@@ -157,12 +144,6 @@ export function matches(actual: string[], expected: string[]): boolean {
)
}
// Why: a spec is reachable by its canonical path plus any declared aliases — one
// definition so resolution, validation, help, and agent-context never disagree.
export function specPaths(spec: CommandSpec): string[][] {
return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path]
}
export function supportsBrowserPageFlag(commandPath: string[]): boolean {
const joined = commandPath.join(' ')
if (['open', 'status'].includes(commandPath[0])) {
+1 -1
View File
@@ -293,7 +293,7 @@ describe('orca cli browser page targeting', () => {
})
})
describe('orca cli browser tab profiles', () => {
describe('orca cli browser profile management', () => {
beforeEach(() => {
callMock.mockReset()
})
File diff suppressed because one or more lines are too long
+18
View File
@@ -0,0 +1,18 @@
export type CommandSpec = {
path: string[]
// Why: conventional alternate verbs should resolve without duplicating specs or handlers.
aliases?: string[][]
argumentMode?: 'parsed' | 'passthrough'
// Why: typo recovery must never steer a benign mistake into destructive state changes.
destructive?: boolean
summary: string
usage: string
allowedFlags: string[]
positionalArgs?: string[]
examples?: string[]
notes?: string[]
}
export function specPaths(spec: CommandSpec): string[][] {
return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path]
}
+1 -2
View File
@@ -1,5 +1,4 @@
import type { CommandSpec } from './args'
import { specPaths } from './args'
import { specPaths, type CommandSpec } from './command-spec'
// Why: rank the live registry so typo recovery cannot drift from accepted paths.
+6 -2
View File
@@ -4,7 +4,12 @@ import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from '../runtime-client'
import {
RuntimeClientError,
type RuntimeClient,
type RuntimeRpcSuccess,
getDefaultUserDataPath
} from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/types'
@@ -12,7 +17,6 @@ import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
} from '../../main/agent-hooks/managed-agent-hook-controls'
import { getDefaultUserDataPath } from '../runtime-client'
type AgentHookCommandResult = {
enabled: boolean
+1 -2
View File
@@ -1,8 +1,7 @@
import type { CommandHandler } from '../dispatch'
import { formatEnvironment, formatEnvironmentList, printResult } from '../format'
import { getDefaultUserDataPath } from '../runtime-client'
import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client'
import type { RuntimeRpcSuccess } from '../runtime-client'
import { RuntimeClientError } from '../runtime-client'
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
import {
addEnvironmentFromPairingCode,
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.hoisted(() => vi.fn())
const getTerminalHandleMock = vi.hoisted(() => vi.fn())
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalPaneKey = process.env.ORCA_PANE_KEY
vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
describe('orchestration check identity', () => {
beforeEach(() => {
callMock.mockReset().mockResolvedValue({ result: { messages: [], count: 0 } })
getTerminalHandleMock.mockReset()
delete process.env.ORCA_TERMINAL_HANDLE
delete process.env.ORCA_PANE_KEY
})
afterEach(() => {
if (originalTerminalHandle === undefined) {
delete process.env.ORCA_TERMINAL_HANDLE
} else {
process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
}
if (originalPaneKey === undefined) {
delete process.env.ORCA_PANE_KEY
} else {
process.env.ORCA_PANE_KEY = originalPaneKey
}
})
const invokeCheck = (flags: Map<string, string | boolean>) =>
ORCHESTRATION_HANDLERS['orchestration check']({
flags,
client: { call: callMock },
cwd: '/tmp/repo',
json: true
} as never)
it('carries the caller pane key when the environment handle may be stale', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord'
process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord'
getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe'))
await invokeCheck(new Map<string, string | boolean>([['wait', true]]))
expect(getTerminalHandleMock).not.toHaveBeenCalled()
expect(callMock).toHaveBeenCalledWith(
'orchestration.check',
expect.objectContaining({
terminal: 'term_stale_coord',
terminalPaneKey: 'tab_coord:leaf_coord',
wait: true
})
)
})
it('keeps an explicit legacy terminal handle scoped to that handle', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_stale_env'
await invokeCheck(new Map<string, string | boolean>([['terminal', 'term_legacy_worker']]))
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith(
'orchestration.check',
expect.objectContaining({
terminal: 'term_legacy_worker',
terminalPaneKey: undefined
})
)
})
})
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
import { printResult } from '../format'
import { ORCHESTRATION_HANDLERS } from './orchestration'
beforeEach(() => {
callMock.mockReset()
vi.mocked(printResult).mockReset()
})
describe('legacy orchestration CLI inspection', () => {
it('labels legacy rows in plain check output', async () => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_worker',
subject: 'progress',
type: 'status'
}
],
count: 1
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration check']({
flags: new Map<string, string | boolean>([
['terminal', 'term_coord'],
['peek', true]
]),
client: { call: callMock },
cwd: '/repo',
json: false
} as never)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
expect(formatter?.(result)).toContain('msg_legacy [legacy, read-only]')
})
it('rebuilds legacy formatted output without runtime-supplied actions', async () => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_worker',
subject: 'progress',
type: 'status',
body: 'Tests are running.',
payload: '{"phase":"testing"}'
}
],
count: 1,
formatted: '[Reply: orca orchestration reply --id msg_legacy --from term_coord --body "..."]'
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration check']({
flags: new Map<string, string | boolean>([
['terminal', 'term_coord'],
['peek', true],
['format', true]
]),
client: { call: callMock },
cwd: '/repo',
json: false
} as never)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
const output = formatter?.(result)
expect(output).toContain('msg_legacy [legacy, read-only]')
expect(output).toContain('Tests are running.')
expect(output).toContain('[payload] {"phase":"testing"}')
expect(output).not.toContain('[Reply:')
expect(output).not.toContain('orchestration reply')
})
it('preserves runtime formatting when every message belongs to a current Run', async () => {
const result = {
messages: [
{
id: 'msg_current',
run_id: 'run_current',
from_handle: 'term_worker',
subject: 'question'
}
],
count: 1,
formatted: '[Reply: current Run action]'
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration check']({
flags: new Map<string, string | boolean>([
['terminal', 'term_coord'],
['peek', true],
['format', true]
]),
client: { call: callMock },
cwd: '/repo',
json: false
} as never)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
expect(formatter?.(result)).toBe(result.formatted)
})
it('labels legacy rows in full inbox output without hiding their body', async () => {
const result = {
messages: [
{
id: 'msg_legacy',
run_id: 'run_legacy_local',
from_handle: 'term_worker',
to_handle: 'term_coord',
subject: 'progress',
body: 'Tests are running.'
}
],
count: 1
}
callMock.mockResolvedValue({ result })
await ORCHESTRATION_HANDLERS['orchestration inbox']({
flags: new Map<string, string | boolean>([['full', true]]),
client: { call: callMock },
cwd: '/repo',
json: false
} as never)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2]
const output = formatter?.(result)
expect(output).toContain('msg_legacy [legacy, read-only]')
expect(output).toContain('Tests are running.')
})
})
@@ -7,10 +7,12 @@ vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
import { printResult } from '../format'
describe('orchestration worker-start CLI contract', () => {
beforeEach(() => {
callMock.mockReset()
vi.mocked(printResult).mockReset()
process.exitCode = undefined
})
@@ -104,6 +106,47 @@ describe('orchestration worker-start CLI contract', () => {
expect(process.exitCode).toBe(1)
})
it('prints a reveal warning for a live background worker', async () => {
callMock.mockResolvedValue({
result: {
taskId: 'task_1',
dispatchId: 'ctx_1',
state: 'ready',
warning: 'Terminal term_worker is running but could not be revealed.',
effects: [],
residualResources: []
}
})
await ORCHESTRATION_HANDLERS['orchestration worker-start']({
flags: new Map<string, string | boolean>([
['task', 'task_1'],
['agent', 'codex'],
['from', 'term_coord']
]),
client: { call: callMock },
cwd: '/tmp/repo',
json: false
} as never)
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
| ((result: {
taskId: string
dispatchId: string
state: string
warning?: string
}) => string)
| undefined
expect(
formatter?.({
taskId: 'task_1',
dispatchId: 'ctx_1',
state: 'ready',
warning: 'Terminal term_worker is running but could not be revealed.'
})
).toContain('Warning: Terminal term_worker is running but could not be revealed.')
})
it('allows the initial zero cursor when paging worker output', async () => {
callMock.mockResolvedValue({
result: {
+1
View File
@@ -686,6 +686,7 @@ describe('orchestration timeout flag validation', () => {
// the non-consuming all mode instead of the destructive mark-read default.
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
terminal: 'term_worker',
terminalPaneKey: undefined,
unread: false,
peek: true,
all: undefined,
+42 -6
View File
@@ -22,6 +22,7 @@ import type {
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
import {
ORCHESTRATION_LEGACY_RUN_ID,
orchestrationMigrationData,
orchestrationSkillRecoveryData
} from '../../shared/orchestration-rpc-contract'
@@ -76,6 +77,7 @@ const TASK_STATUS_VALUES = [
type MessageSummary = {
id: string
run_id?: string
from_handle: string
to_handle?: string
subject: string
@@ -85,6 +87,31 @@ type MessageSummary = {
read?: number
}
function formatMessageReadOnlyTag(message: MessageSummary): string {
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : ''
}
function isLegacyReadOnlyMessage(message: MessageSummary): boolean {
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID
}
function formatLegacyAwareCheckMessages(messages: MessageSummary[]): string {
return messages
.map((message) => {
const lines = [
`${message.id}${formatMessageReadOnlyTag(message)} [${message.type ?? 'status'}] from=${message.from_handle} "${message.subject}"`
]
if (message.body) {
lines.push(message.body)
}
if (message.payload) {
lines.push(`[payload] ${message.payload}`)
}
return lines.join('\n')
})
.join('\n\n')
}
type LifecycleSendRejection = {
action: 'rejected'
code: string
@@ -555,6 +582,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
)
}
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
const explicitTerminal = getOptionalStringFlag(flags, 'terminal')
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
// Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4.
@@ -573,6 +601,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
try {
result = await callMutation<CheckResult>(client, flags, 'orchestration.check', {
terminal,
terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined,
// Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read.
unread: flags.has('unread') ? true : peek ? false : undefined,
peek: peek ? true : undefined,
@@ -617,7 +646,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
}
printResult(result, json, (r) => {
if (r.formatted) {
return r.formatted
return r.messages.some(isLegacyReadOnlyMessage)
? formatLegacyAwareCheckMessages(r.messages)
: r.formatted
}
if (r.count === 0) {
if (r.timedOut) {
@@ -631,7 +662,10 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
return 'No messages.'
}
const rendered = r.messages
.map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`)
.map(
(m) =>
`${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`
)
.join('\n')
return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered
})
@@ -669,7 +703,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
// Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing.
return r.messages
.map((m) => {
const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`
const head = `${m.id}${formatMessageReadOnlyTag(m)} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`
if (!full) {
return head
}
@@ -790,6 +824,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
state: string
failedStage?: string
lastError?: string
warning?: string
effects: unknown[]
residualResources: unknown[]
}>(client, flags, 'orchestration.workerStart', {
@@ -815,9 +850,10 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
}
printResult(result, json, (worker) => {
const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}`
return worker.lastError
? `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}`
: base
if (worker.lastError) {
return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}`
}
return worker.warning ? `${base}\nWarning: ${worker.warning}` : base
})
},
+4 -2
View File
@@ -5,11 +5,13 @@ import { RuntimeClientError } from '../runtime-client'
import { parseOrcaYaml } from '../../shared/orca-yaml'
import {
getEphemeralVmRecipeResultProjectRoot,
getEphemeralVmRecipeResultWarnings,
redactEphemeralVmRecipeDiagnosticText,
type EphemeralVmRecipeDoctorCheck,
type EphemeralVmRecipeDoctorResult
} from '../../shared/ephemeral-vm-recipes'
import {
getEphemeralVmRecipeResultWarnings,
redactEphemeralVmRecipeDiagnosticText
} from '../../shared/ephemeral-vm-recipe-diagnostics'
// Why: import directly from the doctor module (not the barrel) — it uses Node
// fs/path and must stay out of the browser bundle that imports the barrel.
import { doctorEphemeralVmRecipe } from '../../shared/ephemeral-vm-recipe-doctor'
+1 -1
View File
@@ -88,7 +88,7 @@ export async function sendRequest<TResult>(
)
})
})
socket.on('data', (chunk) => {
socket.on('data', (chunk: string) => {
buffer += chunk
// Why: the server may interleave `{"_keepalive":true}\n` frames with the
// final success/failure frame to keep both idle timers alive during a
@@ -7,33 +7,20 @@ vi.mock('electron', () => ({
}
}))
import { CodexHookService } from '../codex/hook-service'
import { DroidHookService } from '../droid/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { AntigravityHookService } from '../antigravity/hook-service'
import { AmpHookService } from '../amp/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
import { GrokHookService } from '../grok/hook-service'
import { CopilotHookService } from '../copilot/hook-service'
import { HermesHookService } from '../hermes/hook-service'
import { DevinHookService } from '../devin/hook-service'
import { KimiHookService } from '../kimi/hook-service'
import { CodexHookService, codexHookService } from '../codex/hook-service'
import { DroidHookService, droidHookService } from '../droid/hook-service'
import { CursorHookService, cursorHookService } from '../cursor/hook-service'
import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService, geminiHookService } from '../gemini/hook-service'
import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service'
import { AmpHookService, ampHookService } from '../amp/hook-service'
import { ClaudeHookService, claudeHookService } from '../claude/hook-service'
import { GrokHookService, grokHookService } from '../grok/hook-service'
import { CopilotHookService, copilotHookService } from '../copilot/hook-service'
import { HermesHookService, hermesHookService } from '../hermes/hook-service'
import { DevinHookService, devinHookService } from '../devin/hook-service'
import { KimiHookService, kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { devinHookService } from '../devin/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import {
installRemoteManagedAgentHooks,
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { AgentHookServer } from './server'
import { makePaneKey } from '../../shared/stable-pane-id'
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f'
const SECOND_CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f3170'
function line(record: unknown): string {
return `${JSON.stringify(record)}\n`
}
function spawnLine(threadId: string, agentPath: string): string {
return line({
type: 'event_msg',
payload: {
type: 'sub_agent_activity',
occurred_at_ms: 1234,
agent_thread_id: threadId,
agent_path: agentPath,
kind: 'started'
}
})
}
describe('AgentHookServer Codex subagent transcript polling', () => {
const dirs: string[] = []
afterEach(() => {
for (const dir of dirs) {
rmSync(dir, { recursive: true, force: true })
}
dirs.length = 0
})
it('publishes rollout-only children and removes them after their task completes', async () => {
const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-'))
dirs.push(dir)
const parentPath = join(dir, 'rollout-parent.jsonl')
const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`)
writeFileSync(
parentPath,
line({
type: 'event_msg',
payload: {
type: 'sub_agent_activity',
occurred_at_ms: 1234,
agent_thread_id: CHILD_ID,
agent_path: '/root/pr_review',
kind: 'started'
}
})
)
writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } }))
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
hook_event_name: 'PostToolUse',
session_id: 'root-session',
transcript_path: parentPath,
tool_name: 'collaborationspawn_agent'
}
})
})
expect(response.status).toBe(204)
expect(server.getStatusSnapshot()[0]?.subagents).toEqual([
expect.objectContaining({ id: CHILD_ID, description: '/root/pr_review' })
])
appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } }))
await vi.waitFor(
() => {
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
},
{ timeout: 2_000, interval: 50 }
)
} finally {
server.stop()
}
})
// Why: the poll re-arms off the object it just stored; if that identity ever drifts it stops after the first change.
it('keeps polling across successive roster changes', async () => {
const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-'))
dirs.push(dir)
const parentPath = join(dir, 'rollout-parent.jsonl')
const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`)
const secondChildPath = join(dir, `rollout-child-${SECOND_CHILD_ID}.jsonl`)
const started = line({ type: 'event_msg', payload: { type: 'task_started' } })
writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review'))
writeFileSync(childPath, started)
writeFileSync(secondChildPath, started)
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
hook_event_name: 'PostToolUse',
session_id: 'root-session',
transcript_path: parentPath,
tool_name: 'collaborationspawn_agent'
}
})
})
expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1)
appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/perf_audit'))
await vi.waitFor(
() => {
expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(2)
},
{ timeout: 3_000, interval: 50 }
)
const complete = line({ type: 'event_msg', payload: { type: 'task_complete' } })
appendFileSync(childPath, complete)
appendFileSync(secondChildPath, complete)
await vi.waitFor(
() => {
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
},
{ timeout: 3_000, interval: 50 }
)
} finally {
server.stop()
}
})
// Why: a nested non-codex CLI inherits the pane's ORCA_PANE_KEY, so its hook must not tear down the codex poll.
it('keeps polling when a nested non-codex hook lands on the same pane', async () => {
const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-'))
dirs.push(dir)
const parentPath = join(dir, 'rollout-parent.jsonl')
const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`)
writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review'))
writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } }))
const server = new AgentHookServer()
await server.start({ env: 'production' })
try {
const env = server.buildPtyEnv()
const post = (path: string, payload: unknown): Promise<Response> =>
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
},
body: JSON.stringify({ paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', payload })
})
await post('/hook/codex', {
hook_event_name: 'PostToolUse',
session_id: 'root-session',
transcript_path: parentPath,
tool_name: 'collaborationspawn_agent'
})
expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1)
const nested = await post('/hook/copilot', {
hook_event_name: 'Stop',
session_id: 'nested-session',
transcript_path: join(dir, 'nested-copilot-transcript.jsonl')
})
expect(nested.status).toBe(204)
// The nested completion is suppressed, so the pane is still the same live codex turn.
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1)
appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } }))
await vi.waitFor(
() => {
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
},
{ timeout: 3_000, interval: 50 }
)
} finally {
server.stop()
}
})
})
+61 -1
View File
@@ -15,6 +15,7 @@ import {
clearClaudeAnsweredQuestionWait,
createHookListenerState,
getEndpointFileName,
hasCodexTranscriptSubagents,
hasPendingAgentResultText,
HOOK_REQUEST_SLOWLORIS_MS,
markClaudeLeadTurnInterrupted,
@@ -106,6 +107,7 @@ type PaneKeyAliasEntry = {
const LAST_STATUS_FILE_NAME = 'last-status.json'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
const CODEX_SUBAGENT_POLL_MS = 1_000
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt).
@@ -484,6 +486,7 @@ export class AgentHookServer {
// Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
private codexSubagentPollTimers = new Map<string, ReturnType<typeof setTimeout>>()
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
private promptSentHashSalt = randomBytes(16).toString('hex')
private closedAgentStatusTabIds = new Set<string>()
@@ -1040,6 +1043,49 @@ export class AgentHookServer {
this.assistantMessageRetryTimers.delete(paneKey)
}
private clearCodexSubagentPoll(paneKey: string): void {
const timer = this.codexSubagentPollTimers.get(paneKey)
if (!timer) {
return
}
clearTimeout(timer)
this.codexSubagentPollTimers.delete(paneKey)
}
private scheduleCodexSubagentPoll(
source: AgentHookSource,
body: unknown,
original: EnrichedAgentHookEventPayload
): void {
// Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll.
if (source !== 'codex') {
return
}
this.clearCodexSubagentPoll(original.paneKey)
if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) {
return
}
const timer = setTimeout(() => {
this.codexSubagentPollTimers.delete(original.paneKey)
const current = this.state.lastStatusByPaneKey.get(original.paneKey)
if (!this.server || current !== original) {
return
}
const normalized = normalizeHookPayload(this.state, source, body, this.env)
if (!normalized) {
return
}
const subagentsChanged =
JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents)
const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original
this.scheduleCodexSubagentPoll(source, body, next)
}, CODEX_SUBAGENT_POLL_MS)
this.codexSubagentPollTimers.set(original.paneKey, timer)
if (typeof timer.unref === 'function') {
timer.unref()
}
}
private scheduleAssistantMessageRetry(
source: AgentHookSource,
body: unknown,
@@ -1264,6 +1310,7 @@ export class AgentHookServer {
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
}
this.clearAssistantMessageRetry(previousOwnerPaneKey)
this.clearCodexSubagentPoll(previousOwnerPaneKey)
// Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
this.legacyPaneKeyAliases.set(physicalPaneKey, {
stablePaneKey: toPaneKey,
@@ -1296,6 +1343,7 @@ export class AgentHookServer {
for (const key of paneKeys) {
this.markPaneClosedForAgentStatus(key)
this.clearAssistantMessageRetry(key)
this.clearCodexSubagentPoll(key)
clearPaneCacheState(this.state, key)
this.runtimeObservedStatusPaneKeys.delete(key)
this.promptSentDedupeByPaneKey.delete(key)
@@ -1584,7 +1632,7 @@ export class AgentHookServer {
if (this.lastStatusFilePath) {
this.hydrateLastStatusFromDisk()
}
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
@@ -1627,6 +1675,7 @@ export class AgentHookServer {
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
const enriched = this.applyNormalizedStatus(normalized)
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
}
res.writeHead(204)
@@ -1636,6 +1685,10 @@ export class AgentHookServer {
res.writeHead(204)
res.end()
}
}
// Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects.
this.server = createServer((req, res) => {
void handleRequest(req, res)
})
await new Promise<void>((resolve, reject) => {
@@ -1675,6 +1728,10 @@ export class AgentHookServer {
clearTimeout(timer)
}
this.assistantMessageRetryTimers.clear()
for (const timer of this.codexSubagentPollTimers.values()) {
clearTimeout(timer)
}
this.codexSubagentPollTimers.clear()
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
this.endpointDir = null
this.endpointFilePathCache = null
@@ -1751,6 +1808,7 @@ export class AgentHookServer {
}
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.clearCodexSubagentPoll(resolvedPaneKey)
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
if (existing.payload.state === 'done') {
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
@@ -1816,6 +1874,7 @@ export class AgentHookServer {
statusChanged = true
}
this.clearAssistantMessageRetry(paneKey)
this.clearCodexSubagentPoll(paneKey)
clearPaneCacheState(this.state, paneKey)
this.runtimeObservedStatusPaneKeys.delete(paneKey)
this.promptSentDedupeByPaneKey.delete(paneKey)
@@ -1834,6 +1893,7 @@ export class AgentHookServer {
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
this.clearAssistantMessageRetry(resolvedPaneKey)
this.clearCodexSubagentPoll(resolvedPaneKey)
clearPaneCacheState(this.state, resolvedPaneKey)
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
let clearedAlias = false
@@ -135,6 +135,28 @@ describe('runtime AI Vault session scanner', () => {
)
})
it('keeps a repinned account home instead of stripping it', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: {
useRealCodexHome: false,
substituteCodexHome: '/data/orca/codex-accounts/account-2/home'
}
})
await expect(
prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', {
agent: 'codex',
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
codexHome: '/managed',
executionHostId: 'runtime:env-1'
})
).resolves.toEqual({
useRealCodexHome: false,
substituteCodexHome: '/data/orca/codex-accounts/account-2/home'
})
})
it('fails retryably when runtime preparation returns an invalid result', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} })
+6 -1
View File
@@ -105,7 +105,12 @@ const aiVaultListResultSchema = z.object({
scannedAt: z.string()
})
const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() })
// Why: zod strips unknown keys, so the repin home must be declared or the
// parent would silently drop it and resume under the wrong account's home.
const aiVaultPrepareSessionResumeResultSchema = z.object({
useRealCodexHome: z.boolean(),
substituteCodexHome: z.string().optional()
})
export function getSavedRuntimeAiVaultHostInfos(
userDataPath: string
@@ -1,6 +1,8 @@
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import {
buildOpenCodeSqliteCandidatePath,
splitOpenCodeSqliteCandidate
} from './session-scanner-opencode-sqlite-paths'
import type { SessionFileCandidate } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
@@ -0,0 +1,5 @@
export function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
@@ -1,5 +1,5 @@
import type { CodexUsageSnapshot } from './session-scanner-types'
import { asRecord } from './session-scanner-values'
import { asRecord } from './session-scanner-record-value'
export function tokenTotal(value: unknown): number {
const usage = asRecord(value)
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import type {
AiVaultAgent,
AiVaultScanIssue,
AiVaultSession,
AiVaultSessionPreviewMessage

Some files were not shown because too many files have changed in this diff Show More