Skip to content

Harden the browser companion and support parallel action pages - #22

Open
LeZergan wants to merge 3 commits into
devnoname120:mainfrom
LeZergan:pr/browser-companion
Open

LeZergan wants to merge 3 commits into
devnoname120:mainfrom
LeZergan:pr/browser-companion

Conversation

@LeZergan

@LeZergan LeZergan commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

Split out of #20, as requested. This PR covers the browser companion (TypeScript) plus the small native protocol change it needs. It is based on current main (v2.3.29) and merged with upstream's recent companion work, so there are no conflicts.

Changes

Parallel action pages (protocol)

  • browser-pool.ts can run up to four fixed ChatGPT pages in one private Chrome profile. Each conversation has at most one action at a time.
  • Maintenance (capabilities, account plan, connector refresh, login) waits for every page to be idle. Global HTTP 429 back-off and the actual Send spacing stay shared across pages.
  • Health gains the optional fields actionCapacity, availableActionSlots and activeActionIds. A companion that leaves them out behaves exactly as before.
  • GET /v1/health?ready=1 waits for fresh proof that a session is idle. Ordinary health stays bounded and passive.
  • The schema hash and fixtures were regenerated. The native side (companion.rs) validates the new fields.
  • The default is still one page. The native configuration for more pages comes in a follow-up PR.

Windows Chrome

  • windows-chrome-recovery.ts recovers an orphaned or stale Chrome left on the private profile.
  • windows-priority.ts normalizes renderer process priority.
  • renderer-memory.ts bounds renderer memory on long sessions.
  • shutdown.ts adds an orderly shutdown.
  • process_is_alive on Windows now waits on the process handle. Before, an exited process whose handle was still open counted as alive.

Send reliability

  • conversation-readiness.ts: waits for ChatGPT's editor to hydrate after navigation (long chats can take a while). A missing editor is not treated as a sign-out.
  • editor-recovery.ts: refreshes a stuck editor once before Send, if the opt-in recovery setting is enabled.
  • draft-recovery.ts: journals our own unsent drafts, so a retry can reclaim them without discarding anything the user typed.
  • stop-controls.ts: verifies the Stop and End-turn controls.
  • submission-network.ts: optional network proof of a Send.
  • approval-monitor.ts: watches for tool-approval cards.
  • Rate-limit cooldowns and Retry-After survive a companion restart. Upstream's transcript-load rate-limit detection (conversation_load_rate_limited) is kept alongside them.

Lighter, cheaper page observation

  • observation.ts bounds read-only DOM reads, and browser-window.ts adds a short-lived passive health cache. Together they stop polls from competing with an action for Chrome's main thread.
  • A cancelled read can keep its window lease until it settles, instead of tearing down a live browser.
  • minimal-page.ts, presentation*.ts: optional Simplified and Bones presentation levels (off by default).

Merged with upstream

  • Upstream's acknowledge-then-preflight admission, pairing-window reservation (refresh-pairing, release-pairing), chat-mode compatibility, conversation-limit detection, model-picker refactor and stage diagnostics are all kept.
  • Tests that assumed the old synchronous preflight were updated to wait for the asynchronous one.

Testing

  • bun test ./tests (Bun 1.4.0, Windows 11): 309 pass, 0 fail. The other 161 tests need a live Chrome and are skipped by design.
  • tsc --noEmit: passes.
  • The protocol fixture generator reproduces the committed fixtures.
  • cargo check --all-targets on this branch alone: passes.

🤖 Generated with Claude Code

Adds an optional pool of up to four fixed ChatGPT pages in one private Chrome
profile (one action per conversation; maintenance drains every page; shared
429 back-off and Send spacing), with optional health fields describing
capacity. The companion still defaults to one page.

Also recovers orphaned Windows Chrome, normalizes renderer priority, waits for
editor hydration instead of treating a missing editor as sign-out, journals
owned unsent drafts, bounds read-only DOM observations and passive health
polls, persists Retry-After across restarts, and adds optional Simplified and
Bones presentation levels. Upstream's admission, pairing-window reservation,
chat-mode, conversation-limit and transcript rate-limit handling are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@devnoname120 devnoname120 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 1105978, including pooled admission/idempotency, shared navigation/Send budgets, page ownership, draft handling and packaging. Requesting changes for reproducible regressions.

The strongest blocker is the connector-selection caret regression: the existing native pairing test passes on main and fails on this head before Send. In an isolated control, restoring the caret to the composer end immediately after the new row click makes the same test pass; that temporary diagnostic change was reverted. A second native regression prematurely times out maintenance requests queued behind a healthy connector refresh. Please repair these before merging the reliability/pool changes.

Validation: bun test ./tests: 305 passed, 163 skipped, 2 failed (new package staging tests on macOS). Typecheck, build and all four boundary tests passed. The native chrome-surface and conversation-approval suites passed (44 tests); the pairing suite's first failure causes cascading failures, so I do not count the later cascade as independent defects. Separately reran the first pairing case: main passes, this head fails, the caret control passes. The account-settings suite passes all 7 tests on main; this head reproduces the same maintenance failure seen in CI. Current CI also reports two billing-plan assertion failures that must be reconciled.

Recommendation: not ready. The ownership/retained-cleanup work is substantial and has meaningful tests, but the common pairing path regresses and the native browser tests need to be green.

const selected = await activeComposer(page, 30_000, signal);
// Click the verified app row directly. Enter in the composer can submit the
// mention as a message if React closes/rebuilds the menu between checks.
await exactRow.click({ timeout:CONNECTOR_ACTION_TIMEOUT_MS, signal });

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Restore the composer caret after clicking the connector row

Changing selection from keyboard confirmation to a row click leaves the editor's selection at the old query position. insertPlainTextIntoComposer preserves any collapsed selection already inside the editor, so the subsequent prompt can be inserted before the connector chip rather than at the intended end. On macOS the existing "pairing waits for enabled Send and binds to the new answer after history hydration" test now fails with external_interference before Send: the projected text is the exact prompt followed by an extra newline/space around the retained chip. The same test passes on main. As a control, moving the range to the composer end immediately after this click makes it pass on this head. Keep the click's safety benefit, but explicitly establish the insertion caret and cover this with the native pairing regression.

queuedAbort=()=>reject(new BrowserCompanionError('Browser action was cancelled','cancelled'));
if(signal?.aborted){queuedAbort();return;}
signal?.addEventListener('abort',queuedAbort,{once:true});
queuedTimer=setTimeout(()=>reject(new BrowserCompanionError('Browser companion is busy; a previous operation still owns the shared window')),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not expire queued maintenance before its valid predecessor finishes

The queue deadline is based on the waiting operation's timeoutMs, even when the current owner has a longer legitimate deadline. Connector refresh can hold its lease for 120 seconds, but an account-plan/reveal request queued behind it expires much earlier. The existing native account-settings scenario demonstrates this without a stuck operation: operationMs=1500, cleanupMs=100, hardStopMs=100 gives queued callers 1700 ms, while refreshDelay=2200 is valid and pendingRefresh completes normally. This head throws "a previous operation still owns the shared window"; the corresponding main test passes. Preserve bounded cancellation/abandoned-entry ordering while allowing the supported predecessor to settle, and rerun the queued plan/reveal regression.

const root = fs.realpathSync(scratch);
const source = fs.realpathSync(unpacked);
const target = path.resolve(appRoot);
const within = candidate => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Compare staging paths in a consistent real-path namespace

root and source are realpath-resolved, but target remains only path.resolve(appRoot). With a symlinked temporary directory, these name the same scratch tree using different prefixes, so within(target) rejects a valid destination. On macOS the new package-stage tests fail under the normal /var -> /private/var temporary-directory spelling: bun test ./tests reports two failures, including the basic successful-move case. Resolve the target's existing parent chain into the same namespace before the containment check while retaining the linked-parent rejection; the normal and escaped-parent tests should both pass on macOS as well as Windows.

- After clicking the connector row, place the composer caret after the
  retained chip. A pointer click left it at the typed query, so the prompt
  was inserted before the chip.
- A window operation queued behind a predecessor no longer expires while
  that predecessor is still within its own budget (connector refresh can
  legitimately outlast a plan or reveal request). It still expires once the
  predecessor overruns.
- Package staging accepts either spelling of a linked scratch directory
  (macOS /var -> /private/var) and still rejects an escaped real parent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LeZergan

Copy link
Copy Markdown
Author

Thanks for the detailed repro. Fixed in 810adf7:

[P1] Connector caret

  • After clicking the verified row, selectConnector now places a collapsed caret at the end of the composer, after the retained chip. It uses the editor's own document, so the domino unit fixture still works.
  • I checked this in real headless Chrome: a chip replacing the query with the caret left before it. Without the fix, insertPlainTextIntoComposer produced PROMPT[chip]; with it, [chip]PROMPT.

[P2] Queued maintenance deadline

  • The window queue now tracks the latest time all queued predecessors can legitimately finish. A queued caller waits until that deadline, but at least its own budget.
  • A plan or reveal request queued behind a 120s connector refresh therefore no longer expires early. It still fails promptly when its predecessor overruns its own budget.
  • Two new unit tests in browser-window.test.ts need no Chrome:
    • a 600ms refresh in front of a 220ms-budget queued login() now succeeds; this one fails on the previous head
    • an overrunning predecessor still expires the queued caller

[P2] Package staging path namespace

  • The not-yet-created target is now accepted under either spelling of the scratch root. The real parent chain is still verified (the escaped rejection) before anything is created or moved.
  • New test: a scratch directory named through a linked alias, like macOS /var → /private/var, via a junction on Windows. It fails before the fix and passes after; the existing escaped-parent test still passes.

Validation: bun test ./tests gives 312 pass / 0 fail (161 native skipped), and tsc --noEmit passes.

I couldn't run the native Chrome suites meaningfully here. On Windows, unmodified main already fails 39 of the account-settings + pairing-submit native cases with fixture timeouts, including pairing waits for enabled Send…. Please rerun the native pairing and account-settings scenarios on macOS. I expect both regressions you reproduced to be green now.

The browser companion's unconfigured spacing between conversation loads
goes back to the upstream 180 seconds. Lane tests now configure the
one-second interval they exercise explicitly, a new test pins the default,
and the timing tests run on a frozen clock so they are not load-dependent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LeZergan

Copy link
Copy Markdown
Author

Follow-up review addressed in 79257c9:

  • Default restored: the companion's unconfigured conversationLoadIntervalSeconds fallback is back to 180 s. The lane tests now configure the one-second interval they exercise explicitly. A new test pins the 180 s default, and the timing tests run on a frozen clock so they don't depend on machine load.
  • Package staging on macOS: fixed earlier in 810adf7. The target is canonicalized the same way as the scratch directory, with a linked-scratch test that reproduces /var → /private/var.
  • Live Chrome soak (2 and 4 lanes, 429s, cancellation, account change, one stalled lane): I can't run this here. The native Chrome suites time out on this Windows machine even on unmodified main (39 failures). Please run it on macOS; the lane default stays 1, so it only matters for users who opt into more lanes.

Bun suite on Windows: 312 pass, 161 skipped (live browser), 0 fail.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants