mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
60bd1dfdea3dedb715b5e6c5a50ed142db2c9e49
1327
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
60bd1dfdea |
feat(native-chat): one shell-environment setting for every structured chat (#22387)
* feat(native-chat): one shell-environment setting for every structured chat Structured Codex chats started from the login-shell environment, while structured Claude chats started from Orca's own process environment, so a variable exported in .zshrc reached one and not the other. Both now start from the same base, chosen by a new setting: - on (default): the whole login-shell environment, as a terminal gets - off: Orca's environment plus PATH, locale, SSH_AUTH_SOCK, and the variable names the user lists The setting is re-read each time a chat starts or resumes. It is shown only when Chat UI, the Chat UI default view, and structured native chat are all on. Terminal-backed chat is unchanged. * fix(native-chat): normalize the shell-environment settings when a profile loads A hand-edited settings file could store the variable list as something other than an array, and the structured runtime called `.filter` on it per launch, so a malformed value failed every structured chat create and resume, and the settings pane render. Normalize both keys where the profile loads, the same way the other array settings are, through one shared normalizer the runtime policy also uses. Also pin that an uncommitted name draft survives an unrelated settings re-render. * fix(native-chat): keep the pinned account as the only source of a structured chat's Claude home The session record owns which Claude home a structured chat uses, and the acquisition pin (claudeConfigDirEnvPatch) is the only emitter of CLAUDE_CONFIG_DIR, compared against what the child would otherwise inherit. With the login-shell snapshot as the inherited base, a CLAUDE_CONFIG_DIR exported only in a shell rc flipped that comparison and produced an explicit pin to the CLI default home, which moves the CLI off its default Keychain item. Drop the inherited CLAUDE_CONFIG_DIR in the Claude launch resolver before the pin runs, as Codex already does for an inherited CODEX_HOME. A configured per-agent overlay still passes through, since the record already honors it. * fix(native-chat): drop Orca's own CLAUDE_CONFIG_DIR from a structured Claude child too The process spawner merges Orca's process env under the launch env, so a CLAUDE_CONFIG_DIR exported to Orca itself reached the child around the launch resolver's drop and unseen by the account pin. One helper now strips it from both inherited bases. Also declare the two shell-environment settings on the runtime store contract and add the six new strings to every locale catalog. * feat(native-chat): add shell variables one at a time with a removable list * fix(native-chat): return focus to the name input after removing a shell variable * fix(native-chat): use a neutral placeholder for the shell variable input The empty input showed a grey HTTPS_PROXY as its placeholder, which reads as a saved value, especially right after that exact entry is removed from the list. Use "Variable name" instead, in every locale catalog. |
||
|
|
563dd5487f |
feat(native-chat): show a Codex chat's goal above the composer, and set it from goal mode (#22377)
* feat(native-chat): show a Codex chat's goal above the composer and set it from goal mode Structured Codex chat now treats the thread goal as session state: a banner above the composer shows the current goal (pursuing / paused) with clear, pause/resume and expand; /goal enters a goal mode whose send calls thread/goal/set; the objective is journaled as a user message marked as sent as a goal. The banner is derived from the journaled goal rows, which Codex's resume snapshot refreshes, so a reopened or adopted chat shows its goal. Fixes STA-8159 * fix(native-chat): replace a recorded goal by clearing first, and recover a lost goal-change response - A set while the journal records a goal (any status) clears it before setting, so the new goal starts with its own time and token counters instead of rewriting the old goal's objective in place. - The threadGoal plan answers an unknown outcome from the goal the journal records and reruns otherwise, so one request timeout no longer refuses every later Clear/Pause/Resume as unknown for the mounted session. - The goal-mode chip says "Exit goal mode"; "Clear goal" stays the banner's action on the provider goal. - A typed bare /goal on Enter enters goal mode, the same as picking it. - The renderer reads the goal off the tail of its ordered snapshot; the host's unordered map keeps the by-sequence reader. - Drop the composer's duplicate in-flight guard; the goal controller already serializes changes. - Pin that a counter-only revision reaches a subscriber's live page under its original sequence. * fix(native-chat): keep a bare /goal inside goal mode as the entrance, and pin goal delivery and serialization - A bare `/goal` submitted while already in goal mode re-enters the mode instead of setting a goal whose objective is the literal text "/goal". - The counter-only revision pin now drives the host's own event sink bound to a real journal, so it goes red when the publish after a lifecycle transition is dropped; the previous fake sink never published. - Pin that a set which threw after journaling its objective puts that objective back exactly once when the ledger reruns the same operation id. - Cover the goal controller hook: absent without host support, the loaded window wins over the host's answer, a second change while one is unsettled answers false without a request, and a refused change frees the next one. * fix(native-chat): resume a blocked or usage-limited goal, and keep goal-mode drafts honest - The goal bar offers Resume on a blocked or usage-limited goal, which the provider resumes exactly as it resumes a paused one; a goal whose token budget is spent still offers only Clear. The rule lives beside the other goal facts in shared code so every reader answers it the same way. - A `/goal <text>` typed inside goal mode sets the objective `<text>`, as it does outside goal mode, instead of a goal whose objective is the literal command. - Setting a goal is a host round trip; a draft edited while it was in flight is no longer wiped when the goal lands, matching every other host command. - Pin that a lost status-change response is read as applied only when the recorded goal is in that status, that a cleared row in the loaded window outranks the host's earlier answer, and that the PTY lane is untouched. * fix(native-chat): keep the load-older anchor on the loaded window when a live revision lands below it A live revision of a row keeps that row's original sequence. When the row is older than the client's loaded window, the shared reducer merged it in and it became the load-older anchor, so paging `before` it skipped every row between. A goal's counter-only revisions during a long goal turn reach any client that attached after the goal row left its window, so a reopened chat lost rows on scroll-back. The reducer now admits live rows only at or above the window's oldest row while older rows remain on the host; the journal keeps the revision and the page reader serves it once the window reaches the row. With nothing older on the host the window is the whole journal, so a row below the head is admitted as before. Also drain accepted provider events before a goal set reads the journal to decide whether it replaces a recorded goal. |
||
|
|
a375936c04 |
feat(agent-launch): let a caller reserve the pane its terminal launch creates (#22291)
* feat(agent-launch): let a caller reserve the pane its terminal launch creates * fix(agent-launch): refuse a launch whose reserved pane is already live * fix(agent-launch): refuse a live reserved pane before it is revealed The live-pane refusal used to fire in the executor, after createTerminal had already issued a handle, published the mobile snapshot and revealed the tab. The reveal re-registered a fresh launch config over the running agent's. agent.launch now passes requireFreshPane with a reserved pane, and createTerminal throws AgentLaunchPaneAlreadyLiveError as soon as spawn reports it attached to a live pane. That is before any handle, snapshot or reveal. The spawn reattach itself is the one terminal.create already uses, so the live PTY is never killed, and the stable-pane create claim is still released in finally. The isReattach plumbing added to the launch factory for the old check is gone. A replay-safe launch refused this way on an existing workspace now records a failed ledger row, the same way a name collision does. Before, the row stayed claimed, so every retry got agent_session_operation_unknown. agent.launchReplay passes the code through. On create-worktree the workspace already exists when the terminal is refused, so the row stays unknown. The code is added to the runtime passthrough list so callers can branch on it. The pane key is now in the replay fingerprint, deliberately. It is not placement: group, anchor and focus still stay out of the request and out of the ledger. It is identity. It is written into the pane's PTY environment and names the tab the caller has placed. A retry that reserved a different pane is therefore a different request. Replaying the first answer would return a key the new reservation can never find. This matches terminal.createAgentSession, which also fingerprints its tab and leaf ids. The key is only folded in when present, so every existing digest is unchanged, and a test pins that. The wire schema now refuses a tab id the runtime would not adopt as sent: one with surrounding whitespace, which the runtime trims, and one longer than 512 characters, which the spawn reservation does not key on. It reuses the tab-id schema that Placement uses. * test(agent-launch): pin that a refused live pane issues no handle The refusal test named handle issuance but only asserted the reveal, so a throw moved to just before the reveal would still pass. Assert no terminal is registered, with the attach test as the positive control. |
||
|
|
17ffbf3b31 |
fix(runtime): answer startup terminal queries for background-created terminals (#22384)
* fix(runtime): answer startup terminal queries for background-created terminals A runtime-created terminal (orchestration worker-start, `orca terminal create`) has no renderer pane until the user opens its tab. Main only answers terminal queries for PTYs the renderer marked hidden, so on a fresh app sitting on the landing screen nothing answered the agent's startup cursor-position query. Muse waits ~2s per unanswered CPR and then exits 0 with no output, which surfaced as `agent_readiness: timeout`. Background runtime spawns now carry initiallyHidden, mirroring the renderer's hidden-at-spawn path: fresh daemon sessions are marked before byte zero, the committed id is marked and paced as backgrounded, and the mark is released on failure, reattach, or adoption. A pane that later mounts visible unmarks and restores from the model snapshot as before. * fix(runtime): keep background PTYs hidden and paced across renderer reloads A runtime background spawn has no renderer pane to report visibility or re-mark it hidden, so it synced as foregrounded (no backpressure thinning) and a reload/crash gate reset cleared its hidden mark, leaving startup queries unanswered. Track runtime-owned hidden marks: they survive renderer-scoped resets, count as known-hidden for backgrounded pacing until a visible report, and are released by a renderer unmark or PTY teardown. * fix(runtime): don't re-hide a background PTY whose view mounted visible during spawn |
||
|
|
0b16a31e6e |
fix(runtime): budget explicit terminal close for the daemon's immediate-kill verdict (#22385)
* fix(runtime): budget explicit terminal close for the daemon's immediate-kill verdict Explicit terminal close (worker-release, worker-stop, `orca terminal close`) gave the daemon kill RPC a fixed 2s deadline. The daemon's immediate kill captures descendants, SIGTERMs them with a 2.5s verification window, then waits up to 8s for the root's physical exit. An agent that runs exit hooks after SIGTERM (Muse: ~3s) outlived main's 2s timeout, so close reported the PTY unverifiable and worker-release returned release_unknown even though the daemon confirmed the exit ~200ms later. Derive the close budget from the daemon's own immediate-kill reply budget (now in an import-free module) plus 2s for the post-kill inventory check. A process that exits within the daemon's budget is released; a wedged process or unreachable host still times out as unverifiable. * fix(runtime): budget the force-kill retry and exercise an expired close deadline * test(runtime): drop tautological deadline-expiry assertion |
||
|
|
996f9cc306 |
feat(mobile-web-bundle): gzipped 384 KiB ranges over a capability-negotiated mobileWeb.bundle.range (OTA phase C follow-up) (#22381)
* feat(mobile-web): serve gzipped 384 KiB bundle ranges behind a capability Adds mobileWeb.bundle.range with its own strict params and result, so shipped chunk readers see no reply change. The host gzips each range at level 6 and sends identity when gzip does not shrink it, sharing the chunk method's read-slot budget and per-asset verification. status.get advertises mobileWeb.bundle.range.v1 beside mobileWeb.bundle.v1, and the method is allowlisted for paired phones. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): read the bundle range capability and range replies Adds the range reply reader and operation, and picks range or chunk from the status.get capabilities the connection already proved, so an older desktop keeps being paged in chunks with no probe round trip. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * perf(mobile): keep four bundle chunk reads in flight across the whole manifest The fetch ran one worker per asset and paged inside an asset sequentially, so the largest script's 71 chunks were 71 serial round trips while the other readers idled. One window of four chunk reads now covers every (asset, offset) on the host's chunk grid, largest asset first. A read_limited refusal narrows the window and retries the read; eof is still read from the reply. Synthetic manifest (one 71-chunk asset, five small): 72 round trips -> 19. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): add fflate 0.8.2 for gzip bundle ranges Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): decode gzip bundle ranges into a bounded buffer Inflates each range into a buffer one byte past its window, so a gzip bomb costs at most that allocation and an overlong body is visible. A corrupt, truncated or unknown-encoding body refuses as range-undecodable; a body of the wrong decoded length refuses as range-length-mismatch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): pass the bundle read method from the session to the fetch The download reads the capabilities of the gates the reducer decided under and hands the fetch range or chunk. The fetch does not act on it yet; the range read lands on the pipelined window. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the bundle-fetch family under pipelined reads Baseline moves to |
||
|
|
52a1e2875b |
feat(orchestration): accept Muse model and effort for supervised workers (#22383)
* feat(orchestration): accept Muse model and effort for supervised workers `worker-start --agent muse` already launched, but `--model` was refused because Muse had no session-option catalog. Add one that maps worker preferences to `muse --model <id>` and `--reasoning-effort <level>`; it seeds no models, so native-chat surfaces show no picker. opencode stays without `--model`: the opencode 2 TUI (now shipped as `opencode`) rejects the flag, so the refusal now tells callers to rely on the agent's own config. Help, skill guide, and docs list valid `--agent` ids and the agents that accept `--model`. Refs #19823 * test(mobile): repin session route closure for the Muse option catalog |
||
|
|
ebed0964a2 |
feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage. Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com> Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com> Co-authored-by: John Cusack <johncusackccm@gmail.com> Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> * test(agents): cover Muse remote hook registration * test(agents): cover Muse hook and source-control contracts * test(agents): exclude Muse hook metadata from script mode check * test(agents): keep Muse skill picker coverage stable * test(ai-vault): include Muse in every-agent fixture * test(mobile): repin Muse agent icon closure * fix(muse): detect questions and approvals from structured Muse signals Muse 1.3 fires no hook for request_user_input, so a pending question left the pane "working". Its internal reminder subagents also post hooks with their own session ids (even after Stop), which surfaced "tool failed" rows and flipped finished panes back to working. - Read pending questions from Muse's session log (user_input_prompt_requested/settled) via the existing transcript poll, now generalized from Codex subagents to Muse on main and relay. - Drop child-session hooks (SubagentStart ids, or turn_id === session_id). - Treat Notification permission_prompt as the approval wait; PermissionRequest also fires for auto-approved calls, so it only caches the approval card. - Ignore Notification copy as the prompt; poll replays are not new prompts or turn boundaries. - Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook. * perf(muse): parse only question events from the session log Most Muse session-log lines are large model/tool records. Filter raw lines by the user_input_prompt_ marker before JSON.parse via an optional readJsonlCursor line filter. * fix(muse): unwrap batched log records and scope questions to the live turn Review follow-ups: question events inside retained_frame batches were skipped, and a question left open by a crash or interrupt stayed pending for the pane's life. Share the history scanner's retained_frame unwrapper, and only report a pending question whose run_id matches the hook turn_id. * refactor(muse): drop type assertion in retained_frame unwrap * fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy --------- Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com> |
||
|
|
0b6f60ac3b |
fix(worktrees): a listing that predates a create can no longer retire the new workspace (#22311)
* fix(worktrees): a listing that predates a create can no longer retire the new workspace Creating a workspace while another one was being deleted could land the user on the empty Landing screen with the new workspace's Claude launch cancelled. A worktree listing whose scan began before the create finished (the prepared checkout is hidden from listings while it is still locked) was applied after the create returned, and the renderer read the new worktree's absence from that authoritative listing as a deletion: it purged the workspace's tabs, tombstoned the pending structured launch (so the host session was retired the moment it published) and cleared the active selection. Renderer: each provider invocation records the local create sequence when it begins (shared by every caller that coalesces onto it). The merge no longer drops or retires a worktree this client finished creating after that point, and the change-event diff purge defers to what the merge actually dropped. Hydrated rows keep being reaped. Host: a scan a worktree mutation overtook is published as non-authoritative instead of authoritative, and the create paths invalidate scans as soon as the new worktree is listable rather than only at the end of the create. * fix(worktrees): an overtaken scan re-derives the catalog, and the create fence rides every listing A scan a worktree mutation overtook was published as non-authoritative with a failure reason, which lit the sidebar's scan-failed indicator, hid external worktree cards and emptied the visibility dialog after a routine create/delete overlap, and its early return skipped the abort, currency and dual-owner guards every other branch honours. The listing now re-scans a bounded number of times instead (the invalidation already dropped the cache, so it scans afresh or joins a sibling's scan) and past the bound answers non-authoritative through the same guard chain, with no failure reason. The renderer's create-sequence inception is now captured where the provider invocation starts and released only when it settles, so a waiter released early cannot reset it for a later joiner, and the direct-SSH scheduler's refresh -- the only listing on hosts with no scan cache -- now carries it too. A re-created worktree id is re-inserted at the back of the create-record eviction order so the bound holds. * fix(worktrees): the host re-runs a scan a worktree change overtook, for cached and SSH listings alike; the renderer create fence is removed * fix(worktrees): the runtime listing re-runs a scan a worktree change overtook, and a headless host bumps the generation it witnesses * test(runtime): the change-invalidation suite lists through the public runtime method instead of casting to a protected one * fix(runtime): the runtime's own worktree change event bumps the scan generation its listing witnesses A headless host has no window notifier, so a delete (and every other change the runtime announces) never moved the generation the runtime listing re-runs against; a listing the delete overtook published the removed row as authoritative until the next refresh. The bump now rides the runtime's change event, ahead of the send, as the desktop notifier already does. * fix(worktrees): a listing that spends its re-scan bound logs the churn on the host Past the bound the listing answered without a trace, so continuous create/delete churn could not be diagnosed from a host log. One warning names the repo and the number of overtaken scans; a caller that withdrew stays silent. |
||
|
|
60c43695e5 |
feat(agent-launch): report the pane a terminal launch created (#22108)
* feat(agent-launch): report the pane a terminal launch created A `term_*` handle is a main-side mapping the renderer cannot resolve (terminal-handle-links.ts:309), so a client that draws its own tabs had no way to name the tab it had just asked `agent.launch` to build. The runtime already mints that pane, bakes it into the PTY's environment and hands it to its own reveal; the surface factory then dropped it on the floor. Carry it through as `paneKey` on the terminal outcome. Identity, not placement: where the pane goes — which group, what order, whether it takes focus — stays with whichever client is drawing, and nothing here rides the wire for it. One field rather than a tabId/leafId pair, because the key already holds both and two copies of one fact can disagree. Absent when this launch minted no pane: a reused terminal was already running, and a worktree-create startup terminal is built by the create, which reports only a handle. Naming the wrong surface is worse than naming none. Optional on the wire and optional on the read side. Mobile parses the receipt with a loose object and is deliberately mode-blind, so it ignores the field; the persisted-row guard checks it when present and accepts a row written before it existed, because a read rule stricter than the write side turns one odd row into a refused replay. * fix(agent-launch): retain startup terminal pane identity |
||
|
|
eb92222e7f |
feat: support Antigravity as supervised worker (#21705)
* feat: add supervised Antigravity worker support * fix: address Antigravity worker review findings * fix: stabilize Antigravity readiness detection * fix: allow Antigravity resume footer after readiness * fix(antigravity): make agy reach worker_done as a supervised worker Three defects each blocked `orchestration worker-start --agent antigravity --worktree new-child` at the agent_readiness stage. 1. Readiness never fired. The composer check required the trimmed line to be exactly one character, but agy 1.2.7 launches in accept-edits mode and paints it into the caret row (`> Accept-edits mode: ...`). Widened narrowly to a bare `>` or `> <name> mode:`; matching any `> <text>` would make every menu dialog read as ready, since they all prefix their highlighted row the same way. 2. No trust artifact for agy. Added markAntigravityWorkspaceTrusted, writing ~/.gemini/antigravity-cli/settings.json under `trustedWorkspaces` — verified empirically against agy 1.2.7, and distinct from the Gemini CLI's trustedFolders.json, which agy does not consult. Trust is exact-path and not inherited by subdirectories, so each child worktree needs its own entry. 3. The orchestration path skipped the preset. Orca has two trust dispatch chains: the renderer's preflightAgentTrust and the main-process markLocalWorktreeTrusted. worker-start only takes the second, which matched cursor/copilot/codex and fell through for antigravity, so the trust write never happened while renderer-side tests passed. Verified live end to end: the dispatch settles `succeeded` with worker_done carrying the right task and dispatch ids, and the worktree is appended to agy's settings with sibling keys untouched. Known gap: remote-agent-trust-presets.ts has no antigravity branch. The SSH artifact path is unverified, so agy over SSH still stalls at agent_readiness. Recorded in a comment there rather than guessed at. * fix(antigravity): wire trust preset through preload safely * fix: preserve Antigravity readiness across transcript tails --------- Co-authored-by: Neil <neil@stably.ai> Co-authored-by: LielinaH <lielinah@gmail.com> |
||
|
|
0677271709 |
fix(orchestration): reap leaked worker terminals via process-incarnation fallback — stops an unbounded PTY/process leak on Remote Server (OOM / cgroup PID exhaustion) (#18790)
* fix(orchestration): remint live handle from process incarnation on worker release When a durable terminal handle goes stale (rendererGraphEpoch fence), inspectWorkerTerminal re-mints a live handle via resolveTerminalHandleByProcessIncarnation + matchesProcessIncarnation so release/stop/read act on the still-running PTY instead of reporting missing and leaking the agent process tree. - keep main shared host-scope re-exports; add matchesProcessIncarnation - wire observation.terminalHandle through control/stop/release - rebuild release-completion on main structured paths - on missing/unattached + provably exited: settleDead fence first, then same-incarnation settleWorker fall back (archive may block settleDead mid-request); settle before recovery defer * fix(orchestration): derive SSH host scope from the reminted handle; reuse fresh-request recovery guidance for structured workers Addresses two open CodeRabbit review comments on PR #18790. inspectWorkerTerminal read the dispatch authority with the stale durable terminalHandle, so after a remint the lookup resolved nowhere and currentHostScope was always undefined — an SSH worker with no liveness verdict and no persisted host_scope got classified from terminal.connected instead of unverifiable. It now reads the same effectiveHandle every other observation in the function uses. stopStructuredWorkerForRelease told the caller to repeat the release with the same --retry-request, which only replays the stale release_unknown receipt and made a structured-worker close failure permanently unretryable. It now sources releaseUnknownRecovery from worker-release-completion so the fresh-request-ID guidance lives in one place. Pre-commit lint-staged (oxlint + oxfmt) run manually: clean. * test(orchestration): exercise incarnation recovery through runtime paths * test(orchestration): pin the incarnation read scenario to the reminted terminal The read scenario only asserted that the call resolved, so it documented nothing about which handle the read reached. Assert that the handle readTerminal received resolves to the registered pane and incarnation, so the scenario proves the read went through the reminted terminal instead of passing on the incarnation fence's throw. * refactor(orchestration): drop redundant incarnation prefix check; require liveTerminalHandle * feat: add freebuff as a first-class TUI agent (#42) <!-- orca-pr-loc --> <!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. --> | | Files | Added | Deleted | Net | | :--- | ---: | ---: | ---: | ---: | | Test | 0 | 0 | 0 | 0 | | Prod | 28 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$37 | 0 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$37 | <!-- /orca-pr-loc --> ## ELI5 Add Freebuff (`freebuff`) as a recognized first-class TUI coding agent in Orca alongside Codebuff and other supported agents. ## What Changed - Registered `freebuff` across shared TUI agent definitions, configuration catalogs, display names, and telemetry schemas. - Added agent icons, favicons, status mappings, and mobile asset references for Freebuff. - Added localization strings across supported language packs (`en`, `es`, `fr`, `ja`, `ko`, `zh`) and updated locale translation policy. - Documented Freebuff CLI in README agent table (`npm i -g freebuff`). ## Why Freebuff is a CLI coding agent twin of Codebuff (`npm i -g freebuff`). Adding it to the catalog enables users to launch worktrees, run automated sessions, and pick Freebuff directly within Orca. ## Linked Issue N/A ## Visual Proof `N/A` - Catalog registration and metadata definition for CLI agent launch; UI rendering uses existing TUI agent picker and status components. ## Testing - Verified TypeScript contracts, schemas, and catalog configurations. - Tested CLI detection / agent picker integration locally on Linux (`worktree create --agent freebuff`). ## AI Disclosure Assisted by AI coding tooling. ## Checklist - [x] This PR is small and focused - [x] I explained what changed and why (including ELI5) - [x] Before/after screenshots or videos attached for UI changes, or `N/A` with reason - [x] Self-reviewed for correctness, security, and performance - [x] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A) --------- Co-authored-by: Lesley Murfin <lesley@revivebusiness.ca> * test(orchestration): erase method overloads in worker reap fixtures * test: document worker fixture type boundaries * test: simplify worker fixture typing --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: svc-orca[bot] <313947298+svc-orca[bot]@users.noreply.github.com> Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
15472cd4c6 |
feat(native-chat): keep restart recovery available in status bar (#21397)
* feat(native-chat): keep restart recovery available in status bar * fix(native-chat): source the restart offer from the host and retire it on recovery Closing the reconnect dialog spent the durable recovery offer, so looking around before deciding lost the recovery for good. The offer now survives a close, and the status bar carries it — but a durable offer needs a way to die, and it only had a reconnect, an explicit dismiss, and a 24h expiry. The claim's launch-scoped lifecycle moves into its own collaborator, which splits what the host ADVERTISES from the evidence it holds. A resume-capable hold that hands a marked chat its provider child back is the recovery the offer existed to perform, so it stops being advertised and stops being written back at quit, while the marker stays valid evidence — a user who reopened a chat can still ask the agent to carry on. Teardown re-derives the snoozed offer rather than round-tripping raw markers, and this teardown's own witness now outranks the stale claim for the same chat instead of being overwritten by it, which was silently persisting an old turn id and making the next launch refuse the chat that was actually mid-turn. On the renderer the candidate list gets its own producer against agentSession.restartResumable, so the status entry and the dialog read one host-owned answer instead of the dialog pushing its local state at a sibling. The entry re-reads the host before reopening, so a reopened list can never name a chat the host would now refuse; dialog open becomes the external one-shot request rather than a flag mirrored into render state, which is what let a reopen replay the launch answer and re-offer chats already reconnected. Dismiss all is quiet rather than destructive, saves the preference like every other exit, and reports a write the host never confirmed instead of trapping the dialog open. * fix(native-chat): keep a durable offer a launch never read, and settle the one a continuation spent Teardown replaced the recovery capsule with whatever this launch still owed, and a launch that never read the offer owes nothing — so a quit after a failed first read, a disabled flag, or a window that never mounted deleted a recovery the user was never shown. The write-back now distinguishes "claimed and still owed" from "never claimed": the first is re-derived as before, the second carries forward verbatim, because nothing revealed those sessions and the predicate would refuse every one for want of a journal nobody opened. Reconnect and continue spent the same claims Reconnect does but never shrank the offer, leaving the status bar counting chats the host had already handed back and sending the user to an entry that re-reads, finds nothing and does nothing. * fix(native-chat): stop a teardown answering for an offer it could not read Two ways the write-back deleted a durable recovery offer nobody had seen. A take that FAILED left the claim holding an empty list and reporting that this launch had answered for the offer. The markers were still on disk, unread and unknowable, and teardown then overwrote them with its own empty list. It now writes nothing at all unless it has a witness of its own. `owed()` read "has the capsule been touched" where it meant "did anything here LOOK at the offer" — and its own write-back read counted. Teardown is retried when a phase fails, so the second attempt re-derived carried markers against a session map eviction had already emptied, refused every one, and wiped what the first attempt had just carried forward. The flag is now set only by the paths that actually read or act on the offer. The mock guard for the carry could not fail: it indexed the session it claimed nothing had revealed, so re-deriving passed and the verbatim carry was never the reason it went green. It now runs against no indexed session, which is what an unread offer looks like. Also drops the `Not now` row from the preference table, where it was paired with a dismiss method it no longer calls, and asserts the same thing where the snooze is already covered. Splits the marker predicate's journal reader out of the resume host, which was at its line ceiling. * fix(native-chat): clear the corrupt recovery capsule the take refused A capsule whose contents no longer parse made take() throw before it ever reached the clear, so the bad file survived every launch. Nothing else rewrites it now that a teardown owing nothing readable declines to write, and the freshness filter runs after the parse, so the 24h window could not release it either: one corrupt file refused recovery forever. Clear it inside the same transaction that failed to read it, then rethrow, so the poison dies on the next launch while callers still see why the take failed. A clear that fails is swallowed rather than allowed to mask the parse error. Refusing to expose partial candidates is unchanged, and a read that fails for any other reason still writes nothing. * feat(native-chat): make resume the one restart action, and make it actually resume The restart prompt offered two actions: "Reconnect all", which reattached and sent nothing — exactly what opening the chat already does — and "Reconnect and continue", which reattached and asked the agent to carry on. The vacuous one is gone, the "Not now" button and the info popover with it, and the feature is now called resume throughout. "Don't ask again (resume automatically)" now runs the action the button runs: the launch calls agentSession.restartContinue instead of agentSession.restartResume, so the preference means what it says. Several comments asserted the opposite as a structural guarantee and are corrected. agentSession.restartResume stays: no in-app caller is left, but it is a published wire method a non-desktop or older client can call. * fix(native-chat): label the resume button with the number of chats selected The button read "Resume all" whenever every chat happened to be ticked, which described the selection rather than the action. It always acted on the selected chats only. Now it always names that count, with a singular variant so one chat does not read "1 chats". * refactor(native-chat): drop the reconnect vocabulary the resume action left behind Resuming became one action — reattach and ask the agent to carry on — so the notification helpers no longer need to be told which action they are reporting. Every caller passed `continue`; the `reconnect` branch, its helper and its catalog keys are gone. The dialog and the launch path had grown two copies of the same call: same RPC, same response shape, same announce-and-settle. That now lives once in the store module that owns the offer, which also takes the dismiss call, leaving the modal presentational. The two copies had drifted — only the dialog's caught a malformed payload — and the unified one keeps the defensive reading. No behaviour change. `agentSession.restartResume` stays: it is a published wire method even though nothing in the app calls it. * refactor(native-chat): derive the resume selection instead of intersecting it The modal's selection was intersected back against the host's candidate list before every action, as a guard against naming a chat the host never offered. That guard could never fire: the selection was already derived from that same list, so the intersection was the identity. The array of chosen ids is now the derived value and the lookup set falls out of it, which makes the property structural rather than checked. The helper had no other caller and is gone, along with its three tests. Three tests mocked the resume response in the shape the old API returned. Two never reached that branch at all; the third only passed because the unreadable shape happened to exercise the malformed-payload path. All three now use the real shape, and the malformed-payload behaviour — report an unconfirmed delivery, leave the offer standing — gets a test that says so. Also: the candidate reader took two trailing optional parameters, so one caller passed a placeholder `false` to reach the second; they are an options object now. `isFolderWorkspaceId` had no caller outside its own module and is no longer exported. `RestartActionOutcome` only ever describes a continuation row, so it is named for that. `dismissAll` set a busy flag that nothing could render, since it closes the dialog first. Several comments repeated an argument already made in the module they point at. Settings: the automatic-resume description is one sentence again. No behaviour change. * fix(native-chat): make restart recovery explicitly durable * fix(native-chat): preserve dismissal fence across new interruptions |
||
|
|
cd59678394 |
refactor(agent-launch): assemble host startup-plan inputs in one resolver (#22082)
* refactor(agent-launch): assemble host startup-plan inputs in one resolver buildAgentStartupPlan was already one shared implementation, but every host re-derived its argument object by hand from the same four settings (agentCmdOverrides, agentDefaultArgs, agentDefaultEnv, terminalWindowsShell), and the copies had drifted. resolveAgentStartupPlanInputs owns that assembly. What genuinely varies per launch stays a parameter: the host (platform, isRemote), a requested shell, the per-launch agentArgs override, and the picked session options. Fixes a live divergence on the agent.launch path: orca-runtime-create-agent-session passed sessionOptions without sessionOptionsOverrideAgentArgs, so a configured `--model` in agentDefaultArgs reached argv alongside the picked model and won on argv order, while the same launch through worktree.create honored the pick. The plan also reported no applied sessionOptions, so the chat surface could not name the model the user chose. Migrates the four host sites; the eleven renderer sites are unmigrated and still assemble their own inputs. * fix(agent-launch): preserve picked options in draft launches * test(agent-launch): assert draft option precedence |
||
|
|
d60043787b |
feat(agent-launch): carry the launch inputs the host cannot derive (#22037)
* feat(agent-launch): carry the launch inputs the host cannot derive Desktop's launch call sites cannot move onto `agent.launch` while the wire drops inputs they depend on. This adds the three the host genuinely cannot work out for itself, and deliberately adds nothing the host can. - `agentArgs` — the host read only `settings.agentDefaultArgs`, so a saved launch recipe's arguments had no way across. Tri-state is preserved: `null` is "no arguments", absent is "use the settings default". - `cwd` — `TerminalCreateOptions.cwd` already reached the spawn, but nothing on the wire filled it. It also decides the route: only a terminal can start somewhere other than its workspace, so the host now feeds it to `requiresTuiLaunchCommand` and downgrades with `tui_launch_command` rather than running a structured session in the wrong directory. - `launchSource` — telemetry, and the only member of the `agent_started` triple the host cannot derive; `agent_kind` and `request_kind` are computed host-side. Typed `z.string()`, not the closed enum: params are validated by the HOST, so a closed arm set would let an older host refuse a newer client's launch over a label. Attribution must not gate a user action. Not added, because the host already derives them: `launchPlatform` (`getAgentLaunchPlatformForWorkspace`, from the same connectionId/path/ projectRuntime the renderer uses) and `startupCommandDelivery` (a pure function of the agent inside `buildAgentStartupPlan`). Fingerprint: `agentArgs` and `cwd` are in — they change what the call does, so a retry carrying different ones must conflict rather than replay. `launchSource` is out — two buttons producing the same launch are one operation, and folding it in would refuse an honest re-attributed retry. A caller sending none of the new fields digests exactly as before, because the canonicalizer drops undefined keys, so launches admitted by an older build still replay across the upgrade. Arguments reaching a structured route are ignored by an existing deliberate decision (the Agent SDK and app-server version their option sets separately from the interactive CLI), so the host reports it in `warning` instead of overriding the user's preference on the strength of a field that is not evidence about the surface. * fix(agent-launch): forward create-target launch inputs |
||
|
|
2739246058 |
feat(native-chat): light the unread indicators when a structured chat finishes (#21924)
* feat(native-chat): light the unread indicators when a structured chat finishes A structured native chat had no attention producer. The PTY lane reaches the unread markers through use-notification-dispatch, whose liveness reads PTY state and whose admission requires terminal panes, so a structured session — which runs on the execution host with no renderer PTY — could finish a turn with nothing lighting anywhere. A backgrounded chat was the worst case: with no mounted pane there was no reader to notice at all. The host derives the completion, because only the host can. The journal keeps committing whether or not a renderer holds a reader, so the new feed observes each commit at StructuredAgentSessionClientDelivery.publishJournal and emits on every running -> settled transition. That edge runs after the subscriber loop and independent of it, which is exactly why a chat nobody is watching can still complete. It is a separate capability-gated stream rather than a field on the status summary: the summary carries no turn identity and no outcome, and is re-broadcast on every status change, so folding a completion into it would make every status consumer a completion consumer. ONLY `success` LIGHTS ANYTHING. Outcome is A0's provider verdict and is never inferred: a turn the host merely watched stop carries no outcome and produces no event, because absent means UNKNOWN. `completed` alone proves nothing — a provider reports its own API error as a finished turn — so the host emits nothing for it and the renderer filters again on the way in. RECOVERY IS LIVE-ONLY. Nothing is retained, queued or replayed on either side. A subscriber learns what settles while it is subscribed and nothing else; on reconnect it re-opens an empty stream and whatever landed during the gap is gone. A retained completion would be a durable "unread is owed" obligation with nothing to retire it, and a reconnect would then light the dot for work the user already read. Tests on both sides pin this so a later refactor cannot quietly turn it into catch-up. The dot itself reuses the neutral policy in attention/agent-attention-policy and #21274's structured surface adapter, so suppression, acknowledgement and addressing keep exactly one implementation and the surface key is never omitted to evade a check. No second suppression rule is introduced. OS delivery is deliberately not wired: this calls applyAgentAttentionUnread, not applyAgentAttention. Also narrows the completion feed's journal dependency to the newest-turn reader it actually uses, and adds journal.newestTurn() beside the existing activeTurnId() on the one shared by-sequence scan rather than a second scan. * test(cross-version): register the turn-completion subscribe on the wire manifest The cross-version gate asserts the structured surface's method list by name and count, so an additive method has to be declared there deliberately. Adding the entry makes the suite call it in both skew directions and stubs the host side, which is the statement the gate exists to force. * fix(native-chat): rebaseline completion feed after rewinds |
||
|
|
dc8cf30554 |
fix(native-chat): end a structured turn when the agent reports it failed (#22047)
* fix(native-chat): end a structured turn when the provider reports it failed (#22044) A turn reads as working while its durable turn row says `running`, and only two events could write a terminal row: the provider's turn-completed notification and the provider process going away. A provider error that ends a turn is neither, so the row stayed `running` with nothing re-deriving it, and the chat counted "Working for N" for the life of the session. Codex reports such a failure as an `error` notification naming the turn it ended, with `willRetry` distinguishing it from a stream error it is about to retry. That frame now settles the turn it names. Claude's CLI reports the same through its session-state frame, whose `idle` arm the SDK documents as the authoritative turn-over signal; that now settles the open turn too. Codex's `thread/status/changed` deliberately settles no open turn: the app server clears `running` on every error, including ones it reports as not affecting turn status, so a turn still open there is still running. What it does settle is a send whose dispatch was never answered — a timed-out dispatch is recorded as unverified delivery, reads as work still owed, and nothing in a live session retired it. Retiring it never makes the send re-deliverable. Splits the codex notification translator so the file stays inside its line budget. * fix(codex): defer idle dispatch release until turn end * fix(claude): enable session state lifecycle events |
||
|
|
83a031c081 |
refactor(agent-status): delete two launch-config accessors left with no callers (#22032)
#21844 removed the Codex launch-argument attention suppressor, which was the last production caller of two launch-config lookups. Both were left in place for a follow-up; this is it. `getAgentLaunchConfigForStatusMetadata` (renderer store) looked a launch config up from a loose metadata bag. Its sibling `getAgentLaunchConfigForStatusEntry` takes a real status entry and still serves the one live consumer, cold restore resume startup. Deleting the metadata accessor also orphaned its `getLaunchConfigForStatusMetadata` helper and the `AgentLaunchConfigStatusMetadata` parameter type, so those go too. `getAgentStatusLaunchConfigForPaneKey` (main runtime) returned a pane's launch config behind a launch-token fence. Its two remaining references were assertions in the launch-authority retirement test. They were a second view of a state bit the test already pins: retirement nulls `pty.launchToken`, and the surviving `verifyOrchestrationCompatibilityCaller` assertion fails when it does not. Verified by ablation — disabling only the `launchToken` nulling fails that assertion with the accessor already gone, so no coverage is lost. Retirement never cleared `launchConfig` itself, so there was no second property hiding in those assertions. Test mocks that existed only to satisfy the removed store method are stripped; the tests themselves are about other behaviour and stay. No behaviour change. |
||
|
|
663d670878 |
feat(agent-launch): deliver a launch prompt to a terminal agent (#21891)
`agent.launch` could hand its initial text to a structured session but not to
a terminal. The contract already anticipated the terminal half — the
`handed-to-terminal` arm has been declared in agent-launch-intent.ts since the
receipt was written and had zero producers — and the executor's own docstring
recorded the assumption behind the gap: that a terminal's paste belongs to the
pane owner. That assumption is what this overturns. The host owns the PTY, so
it can write into one whether or not any window is open on it, which is why
mobile and the CLI got an agent and no prompt.
A terminal takes its prompt one of two ways, and which one is not a
preference. `argv` exists so multi-line and special-character text reaches a
CLI as one argument rather than keystrokes, and it has no readiness race
because the text is in the process's arguments at exec time. So an agent whose
CLI accepts a prompt argument gets it on the launch command, and only a
`stdin-after-start` agent — plus any reused terminal, whose process started
before the launch existed — is written to as a bracketed paste.
That fork is asked once. `agentPromptRidesLaunchCommand` is derived from the
same injection table `buildAgentStartupPlan` branches on, and
tui-agent-prompt-transport.test.ts pins the two against each other for all 37
agents, so adding an agent or changing its mode fails loudly instead of
silently dropping that agent's prompt.
Reused rather than rebuilt: `sendTerminalAgentPrompt` is the runtime's one
agent-prompt writer (bracketed paste, per-PTY serialization, lifecycle
generation pinning, per-agent submit timing, and local/WSL/SSH routing), gated
by `waitForTerminal('tui-idle')` — the same pair orchestration's worker
dispatch already delivers a preamble through. The agent-first create path
needed no new mechanism at all: `startupPrompt` already flows to
`buildWorktreeStartupForAgent`, and the launch had simply been stripping it as
a reserved field without re-supplying its own.
Receipts stay consequences of the act they name. `handed-to-terminal` is
reported only from a launch command that carried the text or a PTY write that
returned; everything unproven under-claims as `not-delivered`. No fourth arm.
The one inversion is a stalled submission, which the verifier raises after the
write: that is reported as delivered, because a resend would paste the whole
prompt a second time into an agent already working on it.
A prompt the launch command cannot carry is refused at the terminal-create
resolver rather than dropped, since that path returns options and has no PTY
to fall back to.
`delivery: 'draft'` remains `not-delivered` for both surfaces. The host could
paste a terminal draft without submitting it, but it cannot observe that the
composer accepted it, so a receipt claiming delivery would be a guess.
No call site is migrated, nothing is added to the wire, and placement and tab
creation are untouched.
|
||
|
|
253f0e3946 |
Fix Antigravity source-control model discovery and retired defaults (#21606)
* fix(antigravity): discover current source-control models and use CLI defaults * fix(antigravity): gate configured models on remote runtime support * fix(runtime): forward default TUI agent for remote git generation * test(runtime): cover inherited agent forwarding |
||
|
|
58a80d996b |
test(runtime): expect the agent's own submit delay in the PTY timing policy case (#21874)
#21665 gave antigravity a per-line settle before Enter, so the delay the test computed from bytes alone is 45 ms short of what the runtime waits. Under fake timers that leaves the submit pending until the real 30 s timeout, which is what every PR's node shard 8/8 has been failing on since it landed. The case now derives the expected delay from the agent's policy, so a future per-agent term moves the expectation with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
30f2bc60f9 |
fix(antigravity): recognize non-Gemini tui-idle prompts (#21231)
* fix(antigravity): recognize non-Gemini tui-idle prompts * fix(antigravity): reject stale composer caret in model picker * fix(antigravity): do not treat a wrap continuation caret as ready An unsent composer can show `> draft` then an indented `>`. That continuation is not an empty input box, so tui-idle must stay false. * test(antigravity): align later bare-caret status expectation --------- Co-authored-by: Neil <neil@stably.ai> |
||
|
|
ea5152f1c2 |
fix(orchestration): line-settle delay for antigravity multiline paste (#21665)
* fix(orchestration): retry Enter after cursor-agent worker-start paste Worker-start dispatches through bracketed paste in the main process; cursor-agent can leave long prompts as "Pasted text +N lines" and swallow the first Enter. Apply the same submitRetryDelayMs path Codex uses in the renderer, but only for agents without the Claude/Codex render gate so hook turn-start reservation stays intact. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(orchestration): line-settle delay for antigravity multiline paste Antigravity 1.2.x expands long bracketed paste slowly ("↑ N more lines") while Orca only waited for byte ingest (~500 ms on macOS). Add submitLineSettleMsPerLine and retry Enter for antigravity; wire agent-aware submit scheduling through the main-process prompt writer and plain terminal.send suffix path. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(orchestration): antigravity line-settle only; drop unverified retry Address PR review: revert accidental pnpm-lock.yaml churn, remove cursor and antigravity submitRetryDelayMs until live-verified, keep submitLineSettleMsPerLine for agy multiline paste, and move the regression test out of the 900+ line runtime submission suite. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
4085e1cf60 |
fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries * fix(memory): bound transient filesystem registries * fix(memory): cap path and locale caches * fix(memory): bound runtime recovery registries * fix(memory): bound host mirror gap verdicts * fix(memory): bound shell startup env cache * fix(memory): bound gitlab host context cache * fix(memory): release removed ssh generations * fix(memory): expire cloud refresh replay guards * fix(memory): release retired plugin generations * fix(memory): bound plugin log key retention * fix(memory): bound automation authority generations * fix(memory): bound native chat enrichment cache * fix(memory): bound web session tracking generations * fix(memory): bound codex credential absence paths * fix(memory): bound WSL canonical path cache * fix(memory): bound sparse checkout cache * fix(memory): bound shared directory cache * fix(memory): bound advertised URL scan snapshots * fix(memory): bound automation manager cache * fix(memory): bound web session reorder intents * fix(memory): bound web session focus intents * fix(memory): bound web session handoffs * fix(memory): bound automation dispatch tokens * fix(memory): bound host mirror waiters * fix(memory): bound retained session activity * fix(memory): bound retained session activity * fix(memory): bound web session close intents * fix(memory): bound cloud session cache * fix(memory): bound WSL home cache * fix(memory): bound SSH capability cache * fix(memory): bound trust grant cooldowns * fix(memory): bound WSL auth drain state * fix(memory): bound Linear workspace credential cache * fix(memory): bound local Git capability cache * fix(memory): bound WSL Git environment cache * fix(memory): bound WSL Git environment cache * fix(memory): bound WSL preflight cache * fix(memory): keep hot cache entries warm * fix(memory): preserve generation fences across eviction * fix(memory): close remaining eviction fences * fix(memory): align evicted upstream generations * fix(memory): trim successful capability probes * fix(auth): retain expired refresh replay evidence --------- Co-authored-by: m4air <m4air@Mac.localdomain> |
||
|
|
d86d5cbbee |
fix(mobile): settle browser dialogs on the stream that reports them, map taps through the page scale, and sweep the frame budget on the real encoder (OTA phase C, C6.7) (#21799)
* fix(browser): settle a page's dialog on the stream that reported it (OTA phase C, C6.7) Chromium hands `Page.javascriptDialogOpening` to one CDP session and takes the answer only from that session; a client attaching afterwards is told `No dialog is showing`, and every renderer-bound command it sends first blocks behind the dialog it was sent to clear. Measured on Chromium 1217, 2026-09-20. `browser.dialogAccept` and `browser.dialogDismiss` went to the agent-browser bridge, which is always a later client, so the reply never reached `Page.handleJavaScriptDialog`: the page stayed blocked and its next dialog never opened. The screencast is the session that reported the dialog, so it is the session that answers it. With no stream live on the page the bridge path is unchanged. No RPC shape changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the browser dialog card until the page takes the answer (OTA phase C, C6.7) The card was cleared on the press, before the reply was sent. A page blocked on a dialog is still blocked until the host settles it, so the pane reported an answer the page never got and left the user looking at a stream nothing could move. The host's `dialogClosed` is what says the page took it, and that already clears the card. The port-pair case runs the pane against a host that only moves the page when the dialog is answered: alert, OK, the card stays while the reply is in flight, then the confirm raises its own card and resolves with the button's value. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): map a tap through the page scale the frame was painted at (OTA phase C, C6.7) Mobile view emulates a phone viewport, and a page with no `<meta name="viewport">` lays out at Chromium's 980 px default and is scaled into it. The frame metadata says so: `deviceWidth` stays the emulated width and `pageScaleFactor` carries the ratio. The browser's input commands take page CSS pixels, so a tap sent in the frame's own device space landed at that fraction of the aim — 41% on the phone, which is how the C6.6 proof found it. The frame geometry now carries the scale the frame was painted at, and both the tap map and the finger-sized click radius go through it. Measured on Chromium 1217, 2026-09-20: `scrollOffsetX/Y` must not be added — the frame is the visual viewport and the commands take viewport-relative coordinates, so a click sent at `device / scale + scrollOffset` landed a screenful past its target while `device / scale` hit it. Web view mode reports a scale of one and is unchanged, and so is a frame whose metadata carries no usable scale. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): sweep the frame budget on the encoder the product runs (OTA phase C, C6.7) The C6.5 sweep encoded through `canvas.toDataURL` while the pane's frames come from `Page.startScreencast`, so the certification and the product were measuring two encoders. The sweep now drives the screencast, over the same 143 viewports and the same noise, through the same real shell. The two encoders agree to within a thousandth of a byte per pixel, and the screencast is the cheaper of them: across the 111 viewports the budget fits it measured 0.543986 to 0.552964 bytes per pixel, against 0.54470 to 0.55351 from `toDataURL`. `WORST_CASE_JPEG_BYTES_PER_PIXEL` stays 0.56, now stated as the screencast maximum plus 1.3%. So the encoder is not what made the C6.6 device proof drop 1 frame in 41 at 402x593 with the budget on. That frame needs about 0.5649 bytes per pixel, above everything either sweep has seen, and nothing here reproduces it. The docstring records that rather than folding it into the constant. The frame is emulated at one device pixel per CSS pixel and the page carries a viewport meta: headless Chromium composites at the DIP surface size whatever `deviceScaleFactor` says, so without both the canvas is scaled into the frame, the noise averages away and the sweep reads about 0.12 bytes per pixel. Also records what C6 ruling 1 costs, in the pane's docstring: "never dark" holds only for a page that produces some frame that fits. With every frame over the cap the pane sits on its busy spinner over an unpainted viewport, which is the budget's reason for existing. No code change for that. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait on the double buffer's flip, not on a digest (OTA phase C, C6.7) The render check waited for some painted layer to carry a digest other than the previous frame's. `applyFrame` writes the next frame's URI onto the hidden layer as soon as the frame lands and only flips the opacity once the decode resolves, so that predicate is true before the frame is on screen. Measured with a MutationObserver over the style writes, 2026-09-20: the URI landed 80.7 ms after the emit and the flip at 85.7 ms, a 5 ms window in which the wait returns and the visible layer is still the previous frame. The check usually outran it by the round-trip it spends reading the layers back, which is why it failed once in CI (#21790, |
||
|
|
d5dc7b9cf8 |
feat(mobile): budget the terminal snapshot on serialized bytes and hold live output instead of ending the stream (OTA phase C, C7.3) (#21785)
* fix(mobile): budget the mobile terminal snapshot on the bytes it serializes to (OTA phase C, C7.3, ruling 1) The desktop trims a mobile snapshot to 512 KiB of raw terminal text. A client reading it through the page bridge measures the serialized event against a 640 KiB frame cap, and an ANSI snapshot is mostly ESC bytes, each of which JSON spends six on. Measured here on a colour-dense 80-column screen: the raw budget hands back 465,766 bytes that serialize to 669,268 — 102.1% of the cap — so `deliver` answers `cancel(id, 'overflow')` and the terminal is dead before its first live byte, with no recovery that does not reproduce it. `terminal.subscribe` gains an optional `snapshotByteBudget`. A subscriber that sends one is trimmed against the JSON its payload will really cost: the escaped text, plus the metadata it cannot bound from its own side — a path, the OSC-link list, the pending escape tail. A subscriber that sends none, which is every socket client and every older page, keeps the raw byte rule exactly. No negotiation, and none is needed: the field is additive and optional, so an older desktop ignores it and trims as it always did. The page then still has a snapshot over its cap, the shell still ends the stream with `overflow` (C0.3 stands), and the terminal renders its stream-error state rather than a blank pane. The page derives the number from the cap less the event envelope rather than writing it down, so a cap that moves takes the budget with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): hold and coalesce terminal output instead of ending the stream on the window (OTA phase C, C7.3, ruling 2) The shell's backpressure window ends a stream when the page falls 4 MiB behind. That is right for a stream whose reader can survive a gap and wrong for a terminal, whose reader cannot see the hole a dropped chunk leaves — and the window does not wait for a page to go wrong. Measured by the design: the host produces 70.3 MiB/s of JSON and real xterm applies 2.2 MiB/s, so an ordinary `cat` crosses the window in 62 ms. Replayed here through the real ledger against a page draining at that rate, a 5 MB transcript ends the stream after 85 of 107 chunks plain and after 40 of 107 under `grep --color`. Keyed by method on the shell, since the page cannot pick its own window, `terminal.subscribe` now holds what it cannot send, merges consecutive output in escaped bytes under the frame cap, and delivers as the page acks. Nothing is dropped: merging concatenates, and the only exit that loses bytes is ending the stream, which the page is told about. Both transcripts now arrive whole and in order, in 104 and 81 frames, with the largest frame at 622,551 bytes against the 655,360-byte cap. It ends only on the two things that are not slowness: a page that has acked nothing for 20 s, an order of magnitude above the 1.9 s a full window takes to drain, and a backlog past 32 MiB, which at that drain is about 15 s of catching up. Both reach the page as `overflow`, because the shell is the installed app and its page comes from the desktop, so a reason the page's reader has never heard of is a frame it drops rather than an end it acts on. Which one fired, the coalesced-frame count and the peak pending bytes go to the diagnostic log, which is the device proof's only oracle for any of this. Every other stream keeps the byte window exactly, and an event over the frame cap still ends any stream, terminal or not (C0.3). The landed window cases now name a stream the window still governs, so the two rules are never read off each other. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): narrow the event arm the backlog replay reads A binary event carries no `payload`, so the tests-typecheck ratchet refused the reach into it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): narrow the snapshot serializer to the buffer source it reads The changed-code casting gate refused the test's stub runtime, and it was right to: a service-wide type for a function that calls one method is what made the stub need an assertion. The parameter now says what it needs, and the fixture path is no longer one a machine-path grep reads as a leaked local checkout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): measure the snapshot budget by building the payload, not by summing fields (OTA phase C, C7.3, ruling 14) Round one summed the escaped text and four metadata fields. The payload a bridged client assembles carries nine more — `kind`, `cols`, `rows`, `requestId`, `displayMode`, `reason`, `seq` and both truncation flags — plus the `type` and `streamId` it adds, the `serialized` key and the object's own braces. So a snapshot this host accepted at exactly the budget, with `truncatedByByteBudget` false because nothing had trimmed it, published over the cap and the stream ended with `overflow` before a byte was painted. Measured here on a screen sized to land exactly on round one's budget: the published payload is 655,446 bytes against a 655,273-byte budget, 173 over, and the frame it makes is over the 640 KiB cap by the same amount. The metadata is now built by one function that `sendSnapshotFrames` and the budget both call, and the budget stringifies the payload that function produces. Nothing is summed and nothing is estimated, so a field added to the frame is paid for by the budget the moment it is sent. Where a value is not yet known — the truncation flags, and `seq` or `requestId` at a site that has not fixed them — it is measured at the widest `JSON.stringify` can write it, which is a bound rather than a guess, and forcing `seq` to a number also opens the three fields it gates so those are counted too. The budget therefore travels with the publication fields, because the payload cannot be built without them. On the page, the event envelope is now derived in one place in the protocol module and read by both the snapshot budget and the shell's own merge budget, so the two cannot drift; the page pins the number it sends and the host's cases name that pin, since the two programs cannot import from each other. The case that re-implemented the host's measure is gone: it could not have seen this, because it was the same arithmetic twice. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): arm a held terminal's silence clock only while something is pending (OTA phase C, C7.3) The invariant is "armed implies waiting on the page", and round one broke it in the one direction that kills: an ack re-armed the clock and the drain that followed emptied the queue without clearing it. A terminal that had delivered every byte and gone quiet — which is what a terminal does between commands — would die on `overflow` twenty seconds later. The clock is now synchronised after every change to the queue, so it is armed exactly while something is held. A rule that only ever arms is a rule that only ever ends more streams. Red-first: with round one's arming, an idle stream whose queue has drained still reports its clock armed, and firing it ends a healthy terminal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the held-stream cases the rulings name (OTA phase C, C7.3) Six cases nothing covered. Two subscriptions on one shell keep separate backlogs, so a busy terminal cannot end a quiet one. A stream the page unsubscribed mid- backlog posts nothing after, and neither does one that has already ended, however much was still held. A payload that is not output breaks a merge run and keeps its place, because a resize is state the reader applies in order. And the budget boundary is checked on the side that enforces it: a payload at exactly the number the page asks the desktop for is delivered inside the cap, and one the cap cannot hold ends the stream under C0.3. The replay no longer acks unconditionally in its catch-up loop. That was the page behaving better than a page can — it acks on reading frames — and it is what hid the silence clock left armed over an empty queue. The held-stream cases close the window on its frame count rather than on four megabytes of string work. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the event-envelope derivation its own module (OTA phase C, C7.3) `bridge-envelope.ts` is at its line cap and is the protocol's schemas; what a frame costs around its payload is a derivation over them, and two budgets read it — the snapshot the page asks the desktop for, and the output the shell merges. One module, so they cannot drift and neither file is pushed over its limit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: narrow the budget fixtures instead of asserting them The changed-code casting gate refused six `as NonNullable<...>` in the new budget cases, and it was right to: a fixture that serialized nothing is a broken case rather than a null to assert away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor: give the snapshot payload shape its own module (OTA phase C, C7.3) `terminal-snapshot-publication.ts` crossed the root config's 300-line cap, which mobile's own lint does not apply and CI does. The frame's shape and what it costs a client reading it as one payload is a description the budget and the sender both need, so it is the part that leaves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: empty a snapshot the budget cannot fit instead of posting it over (OTA phase C, C7.3, ruling 15) Both trimming loops published the zero-row candidate whatever it measured, and zero scrollback is not a small screen: a wide colour-dense viewport still carries its 24 live rows. A capped subscriber could get one frame over its cap, end the stream on `overflow` and paint nothing — worse than a blank terminal, because a blank one repaints on the next byte of output and a stream that never opened does not reopen. Ruling 15: a budgeted subscriber gets that frame with its text emptied and `truncatedByByteBudget` true, never over and never refused. The raw rule keeps its fallback, so an older page and every socket client are served exactly what they were before. Below the metadata the frame must carry there is nothing left to give up, and that boundary is pinned rather than claimed away. The renderer loop is the same walk reached by a different caller and had no test at all; its runtime parameter is narrowed to the two methods it reads so a case can stub it without a cast. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): report what a held terminal stream did instead of calling it an outlived view The backlog report had no branch in the reporter, so it fell through to the "a view outlived its host" warn and every field it exists to carry was discarded. The key made it worse: keyed by kind alone, one backlog per host was ever logged, and a shell holds one stream per open terminal. That report is the only oracle the coalescing rule has. Nothing crosses to the page saying how much was held or how many frames its bytes arrived inside, and both ways a held stream dies reach the page as `overflow`, because a reason its reader has never heard of is a frame it drops. In production the two rules were indistinguishable. They are now a line each, per stream. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test: give the renderer fixture the source its serializer returns `serializeRendererTerminalBuffer` answers `renderer`, and vitest does not typecheck, so the stub's `headless` passed every run and failed the node typecheck instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix: budget the frame the publication actually sends (OTA phase C, C7.3) The budget and the publication were written out twice, five lines apart, and had drifted at every site: a budget for `{kind:'scrollback'}` approved a frame sent as `kind:'resized'` with a `reason` beside it, and the live module budgeted `pending-output-overflow` while sending `renderer-mount-ready`. It held only because the padded `requestId` and `seq` are absent from those frames and more than covered the difference. Each site now builds one object and hands it to both. `displayMode` cannot travel that way and was a third under-measure nobody had named: the subscribe flow re-reads it from the runtime after the snapshot is serialized and before the frame is sent, so no caller can tell the budget which mode the publication will carry. It joins `seq`, `requestId` and the truncation flags as a field taken at its widest. The mode list resolves the constant to `never` if the runtime gains a mode it does not carry, so a new one is weighed here rather than found on a phone. Red-first needed a second attempt: the first fixture had trimming slack, so three extra bytes fit and the probe could not see the defect it was written for. The case now budgets a fixed screen at exactly its `auto` measure, where the margin is the whole of the test. One figure for the overshoot everywhere, with its basis: 169 bytes over the 655,360-byte cap on a frame carrying an 8-character request id, 247 with a 24-character one. Three places said 169 and one said 173. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): delete two backlog guards no input can reach Both survived mutation because neither is reachable, and neither became reachable when I tried to write a case for it. `next` narrowed the merge ceiling to one frame, but its only caller, `drainTerminalBacklog`, has already narrowed it: the parameter is what one payload may occupy, not what the window holds, so the second narrowing could never change the answer. The parameter now says so and the class no longer needs the frame size at all. The bound still lives in the caller and is still covered: removing it there reds a delivery case. The merge run also compared stream ids, but a backlog belongs to one subscription and every `data` payload on it carries that subscription's single stream id, so the comparison could not fail. The run still stops at anything that is not output, which is reachable and pinned. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): record the invariant the deleted stream-id guard rested on The merge run compares no stream ids because it cannot need to: a backlog belongs to one subscription and every `data` payload reaching it carries that subscription's single stream id. Written down where the run is, because the thing that would break it is a change made somewhere else — multiplexing two streams onto one record would merge their output into one payload under the first id. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
f87359cda6 |
fix(runtime): persist acknowledged terminal tab retirement (#21020)
* fix(runtime): persist acknowledged terminal tab retirement
* test(runtime): drain tab retirement fixture writes before teardown
* fix(runtime): explain a refused workspace terminal close
The Sleep-workspace path threw the raw refusal enum ("stale-terminal") as an
Error message, which reaches a CLI user verbatim and a Sleep toast via
describeSleepFailure. Map each refusal reason to a sentence instead.
Also pins two behaviours that had no coverage: the user-visible outcome of a
republished stale-terminal refusal on the web client (the caller cannot tell it
from a real close), and the one-call-per-close invariant that keeps a successor
terminal alive.
The bounded close retry was NOT implemented: notifier.closeTerminalTab carries
only a tab id, so a second call destroys whatever successor took that id.
* test(runtime): build refusal fixtures without type assertions
The changed-code quality gate rejects new `as` casts. Replace the
branded-outcome cast with refusedMobileSessionTabClose, and model the
wire-skew reason as a decoded host answer instead of `as never`.
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Neil <neil@stably.ai>
|
||
|
|
4d82149fe5 |
fix(runtime): reject stale inventory after PTY lifecycle changes (#21014)
* fix(runtime): reject provider inventory across PTY lifecycle changes * fix(runtime): canonicalize SSH inventory generation keys --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
2741bdad38 |
fix(session): remember closed and initialized runtime tabs (#21630)
* fix(session): persist defaultTerminalTabsAppliedByWorktreeId Host persist snapshots wrote tabs but omitted this write-once map, and full session replaces / hydration treated omission as "never applied". Union the marker across persist and hydrate so default terminals are not re-spawned on every launch or re-attach. Fixes #18117 * fix(runtime): stop re-seeding a runtime-owned workspace the user emptied Focusing a workspace owned by a remote runtime created a terminal every time, and sometimes two. The mirror could never record the closed-last-terminal state. A host snapshot with no terminals produced `nextTerminalTabs === null`, which `withWorktreeEntry` turns into a deleted key -- and a missing row is exactly how every seeder spells "never initialized" (initial-terminal.ts). Keep an explicit empty row instead, so the remote path reads the same tombstone the local one already honours. A worktree that never had a terminal still gets no row, because `sameTerminalTabs` treats a missing row and an empty one as equal; removal frames and synthesized unpublished frames keep deleting, since neither is evidence the user emptied anything. The duplicate had a second cause. `requestedInitialTerminal` was a `let` inside the session-tabs subscription closure, so "one focus creates at most one terminal" held only for as long as that closure lived. Its effect re-runs whenever the environment, connection generation, pairing revision, or session-ready flag settles -- all of which move during a workspace switch -- so a second closure re-armed the flag while the first create was still in flight. That is the asymmetry in the report: one terminal when arriving from the landing screen, two when arriving from another workspace. Latch the bootstrap per worktree in a module-scoped set instead, modelled on web-runtime-wake-terminal-respawn.ts, released when the create settles. The closure flag stays alongside it so a failed create still does not retry on every later frame of the same subscription. Fixes STA-6173. * fix(runtime): harden the runtime-owned initial-terminal bootstrap latch Follow-up on the STA-6173 fix, addressing restore-time safety gaps found in review. - Decline the bootstrap on a synthesized unpublished frame (`UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH` at version 0). That frame is the runtime saying "ask me later", not a host with zero terminals; seeding on it can duplicate a pane the host is about to republish after a restart. This is the same "ask me later" frame the tombstone write already refuses to treat as the user emptying the workspace. - Release the module-scoped bootstrap latch on worktree tracking teardown and environment teardown, mirroring web-runtime-wake-terminal-respawn. A create RPC that never settles during a disconnect would otherwise leave the per-worktree key set and suppress the next bootstrap after reconnect. Tests: - New per-worktree and per-environment latch-release cases. - New "unpublished frame declines" bootstrap case. - New hook-level regression pinning the second defect end to end: a forced active-subscription re-run while the first create is in flight seeds exactly one terminal (two on the pre-fix tree). * fix(runtime): key the initial-terminal bootstrap latch per environment Addresses review on the STA-6173 hardening. - Key the bootstrap latch by (environment, worktree), not worktree alone. A worktree id is `repoId::path` with no host component, so the same id can be live on two paired runtimes at once. The latch was cleared wholesale on any environment teardown, so tearing down environment A released environment B's in-flight key and a fresh B subscription could seed a duplicate — the STA-6173 defect through another door. Environment teardown now clears only its own keys; worktree teardown clears only that (environment, worktree). - Hold the latch after a successful create until a mirrored `tabsByWorktree` row exists. The snapshot refresh the create awaits can resolve on an empty, unconfirmed frame that leaves no row; releasing then let a later effect re-run seed a second terminal. A failed create still releases for retry. The latch claim/create/release now lives in web-runtime-initial-terminal-bootstrap-dispatch.ts, keeping active-session-subscription within its line budget. Tests (each mutation-tested against its own regression): - Cross-environment: a create for env B in flight, env A torn down, a fresh B closure must still decline. Fails when env teardown sweeps all environments. - Hold-until-row: a create that resolved without mirroring a row must not let an effect re-run seed again. Fails on unconditional release. * fix(runtime): release the bootstrap latch when the create returns failed createWebRuntimeSessionTerminal never throws: the operation catches RPC and network failures and returns `{ status: 'failed' }`. The dispatch helper released the latch only from `catch` (dead for that path) or once a tabsByWorktree row existed (false after a failure), so a failed create left the latch held and suppressed every later auto-seed for that environment's worktree until teardown -- the opposite of its own doc comment. Capture the outcome and release on a returned failure as well, so the next focus can retry. Regression: a create that resolves `{ status: 'failed' }` followed by an effect re-run must create again. Fails on the previous release condition. * fix(runtime): release a parked bootstrap on the mirror's next frame The previous commit released the latch on a returned failure, but a create that *succeeded* with no mirrored row yet had no release at all: the row-conditional check was the only exit for the success path, so a host that accepted the tab while the mirror never got a frame held the latch until environment teardown and suppressed every later auto-seed for the worktree. Give the latch two phases. `creating` blocks other closures while the RPC is in flight. A success with no row is parked as `awaiting-mirror` instead of held, and the next frame the mirror accepts for that worktree releases it -- that frame is the mirror's answer either way (a row now exists and the predicate declines on its own, or the host genuinely has no terminal and a retry is right). A create still in flight keeps its claim: releasing it on a frame would reopen the re-armed-closure race the latch exists to close. Also correct the closure-flag comment: `requestedInitialTerminal` is set only after the dispatch resolves, so a thrown create never sets it and a later frame may retry. The flag records that this subscription already owned a create; it never described a failed one. Regressions, each mutation-tested against its own term: - success with no row, then the mirror's empty answer, then a fresh closure -> must create again (fails when success-with-no-row is held instead of parked, and when the subscription does not call the frame release) - a mirror frame releases an awaiting-mirror claim but never a pending create (fails when the release ignores the phase) * fix(runtime): let a failed create retry inside its own subscription The returned-failure release freed the module latch but the caller still latched its closure-local requestedInitialTerminal whenever the dispatch reported it owned the create, including a create that returned { status: 'failed' }. A thrown failure never set it and retried on the next frame; since every RPC and network failure is reported as a return, the live path was the one that suppressed the whole subscription. The dispatch now reports false for a failure whichever way it arrives. * fix(runtime): close the second re-seed door on an emptied workspace ensureWebRuntimeWorktreeTerminalAfterWake read tabsByWorktree through `?? []`, and the only guard that could have seen presence was &&-gated on the value that erased it, so it could never fire for a tombstone. It runs on every activateAndRevealWorktree, not only after a wake, and a tombstoned workspace routes exclusively here: the stream-frame path returns at localTerminalCount === 0 before reaching its own guard. So the workspace was re-seeded on every focus regardless of the mirror fix. Two states shared one line and now do not. With no rows the workspace is being seeded for the first time and the decision goes to shouldAutoCreateInitialTerminal with presence read by Object.hasOwn; with rows present the question is whether a woke workspace's chrome outlived its PTYs, which the tombstone says nothing about. Both arms are pinned, because reverting only the respawn arm broke no existing test. * fix(runtime): stop the worktree-id re-key inventing a tombstone canonicalizeTerminalSessionWorktreeId read the source row through `?? []` and always wrote the target one, so a workspace with NO tabsByWorktree row came out the other side with an explicit empty one. That is the closed-last-terminal tombstone, and this PR's new activation-path reader honours it, so the re-keyed workspace never gets its initial terminal. Guarded on the source row's presence, which is the guard the sibling keyed maps six lines below already use. Mutating the guard to test emptiness instead of presence breaks the assertion that a real tombstone still survives the re-key, which is the distinction that matters. * fix(runtime): reconcile session tab bootstrap integration * fix(runtime): preserve terminal wake launch options * test(session): annotate cross-project persistence fixture * test(session): remove obsolete typecheck suppression * test(session): keep persistence regression in renderer project * refactor(runtime): remove inert snapshot recovery wrapper * fix(runtime): keep removal-frame import merge-safe * fix(runtime): deduplicate merged removal-frame import --------- Co-authored-by: Wooseong Kim <innocarpe@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> |
||
|
|
ea02d90704 |
fix(omp): answer startup Kitty queries before renderer handoff (#20620)
* fix(omp): answer startup Kitty queries before renderer handoff Forward actual renderer capability through local and remote spawn. Preserve source ranges and following keyboard mode pushes, and retain independent ConPTY color authority. Refs #17081. Secondary review: #17082. Co-authored-by: stevelliu <stevelliu@tencent.com> * test(omp): cover fragmented keyboard modes and ConPTY handoff * fix: preserve keyboard startup intent without terminal colors * fix: negotiate keyboard support for host-authoritative agent launches * fix: keep terminal creation within line budget * fix(omp): negotiate keyboard support for paired web launches * test: remove obsolete message type import after main integration * fix: validate paired launch results and retry incomplete SSH test snapshots * fix(omp): negotiate keyboard support for background paired launches --------- Co-authored-by: stevelliu <stevelliu@tencent.com> |
||
|
|
54a19c2ba3 |
fix(pdf): render CJK text with pdf.js resources
Merged after PR-specific checks passed. CI failures are unrelated baseline findings in ClientHostedBrowserPagePane.markup.test.tsx and pane-title-update-global-scan-budget.test.tsx. |
||
|
|
d966927013 |
fix(omp): submit large prompts in one PTY frame (#21573)
* fix(omp): join prompt submit with large paste * test(omp): cover joined submit timing * ci: rerun PR checks after timing test fix * test(omp): acknowledge joined submit activity |
||
|
|
e8a956e833 |
fix(remote): reassert terminal geometry after reveal (#21568)
* fix(remote): reassert terminal geometry after reveal * test(remote): expose layout queues through fixture * test(remote): narrow layout target assertion |
||
|
|
2bf538a4e1 |
fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see (#21436)
* fix(runtime): detect a same-size terminal artifact swap the granted stat cannot see A local terminal-artifact grant pinned the file as `dev:ino:nlink:size:mtimeMs`. On Linux every one of those can survive an unlink+recreate: ext4 reuses the just-freed inode (measured: 100% of the time), nlink and size are unchanged for a same-size replacement, and the mtime clock is tick-quantized to 1ms, so a swap inside one tick produces a byte-identical identity string. The grant then served the attacker's bytes as if nothing had changed. Local grants now also pin a sha256 of the artifact's content, taken from the same handle as the stat so nothing can swap the file between them, and every local read, preview and write re-checks it before returning or committing content. The stat identity string itself is unchanged: the relay recomputes it verbatim to honour `expectedStatIdentity`, so its format is a wire contract. Remote grants keep the stat-only check and are untouched. This is also the mechanism behind the intermittent `orca-runtime-files-terminal-artifact-io.test.ts` failure on `rejects stale absolute terminal artifact previews before returning changed content`: it replaces an 8-byte artifact with 8 different bytes, so whenever the two writes shared a 1ms tick the product genuinely could not tell them apart. * docs(runtime): record what the terminal artifact grant checks do not close The digest makes the same-size swap detectable; it does not make the sequence atomic. A reader arriving at the access module would reasonably assume otherwise, so write down the measured limits of the stat identity, why the identity string cannot change, and the four windows that stay open — the write path's surviving rename() gap above all. |
||
|
|
e49b3aa0bd |
fix(relay): carry the signed-out reason when the broker's own renewal notices the session loss (#21562)
The relay control socket closes with the reason `signed-out` so the cell can tell paired phones to sign in rather than reporting a bare "host offline". Only the auth coordinator computed that reason. The broker's renewal tick called `closeNow()` with no reason when its token refresh came back empty, and `closeNow` is idempotent, so whenever that tick observed the lost session first — the common case for a session revoked or expired while Orca runs — the coordinator's later `closeNow(SIGNED_OUT)` was a no-op and the cause never left the desktop. `refreshAccessToken` now returns a refusal that carries the reason the coordinator already derives, and both close paths read it from one place. A refusal for any other cause still names nothing: a present-but-unentitled desktop is signed in, and a superseded refresh leaves the close to whoever superseded it. |
||
|
|
b69bc6d5b1 | fix(terminal): preserve escape tails in renderer snapshots (#21578) | ||
|
|
c34b944136 |
feat(github): bind projects to a specific gh account (#13664)
* feat(github): bind projects to a specific gh account Adds per-project `Repo.ghAccount` so repo-scoped gh calls (create-worktree issue/PR search, work items, hosted-review reads and mutations) run as the bound account via ephemeral child-env token injection instead of the globally active gh login. Multi-account resolution is capability-gated (gh >= 2.40) and fails closed when the bound account or host is unavailable; Project View stays ambient by design. Repository settings gains a section for selecting or clearing a keyring-backed account (shadcn `Select`), with mixed-version "not enforced" handling for older remote runtimes. Attached `-Rhost/owner/repo` forms are covered by the host-drift guard and its tests; es/ja/ko/zh catalogs carry the section's strings. `getLocalProjectGhExecOptions` centralizes the binding lookup so every gh execution path picks it up, including the Electron `hostedReview:*` handlers that previously stayed on the ambient login. `gh auth token` (a keyring read) is exempt from the rate-limit breaker gate so a tripped bucket cannot turn a bound-token resolve into a false "unavailable". The `ghAccount` update field and the two binding RPC methods live in the shared RPC params contract; the generated catalog is regenerated. Fixes #13612 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012B3QEP5iP4WGGEpPLtkHqA * fix(settings): make GitHub account refresh secondary * fix(github): satisfy strict casting quality checks * test(rpc): use runtime fixture for repo binding * fix(github): preserve project account for PR worktree lookups * test(rpc): avoid incomplete runtime settings fixture * fix(i18n): add GitHub account refresh label * fix(i18n): refresh runtime required catalog * fix(windows): preserve mobile patch bytes --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai> |
||
|
|
2038376d8e |
fix(terminal): a park must not discard the only copy of a remote pane's scrollback (#21285)
* fix(terminal): keep a client copy of a parked remote pane's scrollback
A remote-runtime pty's bytes never transit the client's main process, so the pane's
xterm buffer is the only client-side copy. The ordinary cold-park unmounted that pane
without capturing it, licensed by TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY — a static
build string that says nothing about whether the host retained this pty's buffer. On
reveal, a host that answers 'no-serializable-buffer' (or stays silent past the request
timeout) collapses to a null snapshot and the pane paints blank: tabs and splits survive,
the scrollback is gone.
Capture before every park, not only the retention-budget force-park, so the reveal has a
copy to replay when the host cannot answer. An unverifiable host answer is not proof the
pane was empty; keep the buffer, never discard it.
Adds ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE so an e2e can reproduce the
host-retains-nothing state, mirroring the existing forced-truncation lever.
* test(terminal): prove a parked remote pane survives a host that answers nothing
The oracle is a token the test types into the terminal before the park and the fixture
echoes back. Nothing replays stdin, so a respawned command cannot reproduce that line —
only the pre-park buffer can. An earlier argv marker passed vacuously for exactly that
reason.
The control ('host retains the buffer') is insensitive to the fix and fails if the harness
never parks, never reveals, or never echoed the token, so the regression case cannot be
green for a harness reason.
* refactor(terminal): validate the paired host terminal RPC shape instead of casting it
The merge-commit consistent-type-assertions gate flags every new `as`. Two were fixture
shapes that a type annotation states directly, and the third hid an unchecked RPC payload —
readCreatedTerminalTab now fails with the shape named rather than surfacing later as an
undefined surface id.
* fix(terminal): let a park capture survive an unhydrated repo catalog
Reading state.repos unguarded threw out of the cold-park effect whenever the catalog was
absent, which would break parking itself. Capture is best-effort evidence; an empty catalog
also fails open in shouldPreserveTerminalScrollbackBuffers, the safe direction for a park.
* docs(terminal): pin why the two unhydrated-catalog fallbacks point opposite ways
shouldPreserveTerminalScrollbackBuffers fails open toward 'remote' because a worktree wrongly
judged local parks with no copy at all. worktree-runtime-owner.ts resolves the same unhydrated
catalog to 'local', which is safe there and would be data loss here. A reader pattern-matching
'fail open' across the two gets one of them backwards.
* fix(terminal): keep a parked pane's scrollback across a reconnect merge
The direct-SSH pull replaces a replaced tab's layout wholesale, and a park capture does not
bump tab.generation — so a just-parked tab is not in locallyPreservedTabIds and the only
client-side copy of its remote scrollback went with the layout it replaced. That is the same
data loss this branch already fixes, one layer down, and it is the layer that decides whether
the fix survives the app update the user actually performed.
Carry the client's leaf-keyed scrollback into the host's layout, filtered to the host's own
root leaves. Structure stays the host's verbatim, so a split it added while we were away still
wins and a leaf it retired still drops its bytes. Local wins a conflict: neither copy is then
the only one, but remote-wins would overwrite the tail captured since the last upload and
propagate that backwards on the next replace-session patch.
Not a generation bump: the pane key is `${tab.id}-${tab.generation}`, so bumping would remount
the pane and destroy the very buffer the capture just serialized, lift the recovery-storm
ledger ceiling, and let a stale local ptyId win through preserveNewerLocalTerminalFields.
* fix(terminal): carry a parked pane's scrollback through the mirrored-layout rebuild
Found in review of this PR by rc-ssh-remoting. chooseRemoteTerminalLayout rebuilds a
mirrored tab's layout from the host's picture and never carried buffersByLeafId or
scrollbackRefsByLeafId forward, though it already receives existingLayout. The host
publishes no scrollback of its own, so ANY session-inventory frame landing between park and
reveal dropped the only client-side copy: the rebuild is bufferless, terminalLayoutEqual
compares buffers so the write is not bailed out, and apply-terminal-records assigns it
wholesale.
Measured before the fix: 336 bytes captured at park, 0 after one forced frame, blank pane on
reveal. After: 411 bytes survive the frame and the reveal repaints.
The e2e passed either way because no frame happened to land in its window, so it was not
covering the destroying event. It now forces one inside the park -> reveal window and asserts
the capture survives it.
An identical fix was written and reverted earlier in this branch as 'no measurable effect' —
that measurement ran on a harness deleting the client profile between launches, so nothing
downstream of persistence could register. It was never actually tested.
* feat(session): add a local-only home for ordinary-park scrollback
localOnlyScrollbackByTabId is a top-level session field, tabId -> leafId -> buffer, that never
rides the remote projection: exportRemoteWorkspaceSession is an explicit allowlist of named
top-level fields, so a new one is omitted for free, whereas anything added to
TerminalLayoutSnapshot is copied whole. It is also outside the two records the mirrored-tab apply
rewrites, so a host inventory frame cannot wipe it.
Registered in every exhaustive session registry ('tabKeyed'), hydrated and scoped like the layout
map, dropped with its tab on close/removal/purge/repo removal/mirrored retirement, copied on profile
transfer, emitted by the incremental patch builder, and capped by pruneLocalTerminalScrollbackBuffers
alongside the shared home — with a per-home test so an uncapped path cannot go unnoticed.
Known ceiling, not widened here: the field routes through the partition router that falls back to
'local' when the repo catalog is unknown at write time (#21295).
* fix(terminal): keep ordinary-park scrollback off the upload, and read both homes through one resolver
The ordinary cold park fires on every workspace hide. Its capture now splits: structure (root,
ptyIds, titles) stays in the shared layout, bytes go to localOnlyScrollbackByTabId. Force-park,
hibernate, sleep and shutdown keep writing buffersByLeafId, because that copy is what a second
desktop cold-restores from; a shared capture clears the local copy so the two homes never hold two
versions of one leaf.
resolveLeafScrollbackBuffers is the only read across the two homes (local wins a conflict: it is
the later write by construction). restoreTerminalPaneLayout no longer reads buffersByLeafId
directly, the capture's merge prior comes from the resolver, and the post-replay release covers
both homes.
Measured with the projection at 20 tabs x 2 panes at the per-leaf cap: the shared-layout shape
exports ~22 MiB per replace-session; the local-only shape exports the bufferless baseline.
* test(sync): pin that the mirrored rebuild carries the client scrollback refs
The carry-through added in
|
||
|
|
ac4dc6599b |
feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) (#21502)
* feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the shell names the screen, and the page routes to it (OTA phase C, C1.2) The shell serves its document at `/` and refuses every other path, so the page's own location matches no route in the tree it carries and expo-router paints Unmatched. Nothing in the document can tell it otherwise, so the screen has to cross the bridge. `init` gains an optional `route: { pathname, params }`. The pathname is held to what a path may be rather than to what a screen may want: rooted, single-slash, no query and no fragment. A protocol-relative `//host` would make `history.replaceState` throw a cross-origin SecurityError and take the mount down with it, and the params are a field of their own so neither side parses a URL. The shell route supplies it, the screen passes it to B4's hook, and the hook holds it for the life of one host: the page routes once, before its first render, so a route that changed afterwards has nothing left to change. The page writes that URL into its history and then mounts. It also hands the same URL to `ExpoRoot` as its `location`, because `ExpoRoot` snapshots `window.location.href` when its module is imported, which is before any frame has crossed the bridge: without it the router reads the `/` the shell served and replaces the page's own path right back. A shell too old to name a route leaves the page with nothing to open, so it paints a panel saying to update the app, built as elements outside React because the route tree is exactly what cannot mount there. Both platforms stop reading the document's URL to decide a load finished. The page rewrites its own path before its first render, so a document that committed at `/` reports finishing at `/h/<hostId>`; reading the path withheld `ready` forever and left the Android WebView hidden behind it. What is left is whether the load committed, which is the question the state machine already answers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the desktop lists a page route, the shell honours it or stays native (OTA phase C, C1.3) The worktree list now renders from the desktop's bundle, and which routes do is negotiated rather than decided on one side. The manifest gains `routes: [{ pathname, grants }]`, written from one declared list the builder checks against the tree it bundled, so a declaration naming a screen with no module fails the build instead of reaching a phone as a page that paints Unmatched. The field is additive because the phone reads the manifest loosely and pins no schema version; the desktop's own writer stays `.strict()`, and the stale comment saying there was no additive path is corrected. The shell answers for what it can do. A route the bundle does not list, or lists needing a grant this app does not implement, settles as `native-route` and downloads nothing; so does a desktop that ships no bundle at all, which is the one blocked verdict that is not a wall, because a desktop with no bundle declares no page route and there is no workspace to refuse. The route is answered before the compat verdict for the same reason: a bundle this shell cannot open is not a reason to refuse a screen it was never going to open. `app/h/[hostId]/index.tsx` mounts the shell when the flag is on and takes the native list back as the fallback, and both routes read the flag through one hook so the census stays the whole census. A tap on a worktree row still opens the native session screen. The page posts `notify { name: 'navigate', href }` behind the `navigate` grant, which is not a convention: `notify` is a closed union, so an older shell refuses the whole frame and the page checks the grant before it posts. The shell pushes the target over the still-mounted view, so Back reveals the page with nothing reloaded. `route-handoff.ts` and its web sibling are the seam, router-shaped so the list's own hook and the recorder's adapter are untouched and no golden moves: the web file wraps the three members that leave the document and hands back any target outside the page routes `init` named. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation is on disk and was hash-checked before the view loaded it, so the same bytes throw again and a retry here would only throw twice; recovery belongs to the shell, which drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the route ref after the commit, not during render Same class as the page-fault ref: render must stay pure because React can replay or discard it. Folded into the one commit-phase effect above the host's. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-route and navigate refs after the commit Same class again: the last two writes this branch adds join the commit-phase effect, so nothing this hook holds is written while React is rendering. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a route no page can open, rather than blanking the WebView (OTA phase C, C1.2) `sendInit` put `options.route` straight on the wire and only the page's decoder checked it, so an out-of-contract pathname made the page refuse the whole `init`, ask again on its 2 s backoff forever, and the shell un-hide a view that would never paint. The only trace was a `console.warn` inside the WebView. Three changes, one failure mode. The host parses the route at construction and serves no session at all when it will not do, reporting it as a shell failure. The pathname rule refuses empty segments, dot segments and backslashes anywhere, because `replaceState` normalises `/../../etc` to `/etc` and `/h/a\b` to `/h/a/b` and the page then renders whatever came out. And the producer encodes the host id it interpolates, which is how one carrying a query, a fragment or whitespace got there. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make a handoff mean the shell took it, not that a frame left (OTA phase C, C1.3) `handOff` returned `client.notifyNavigate(href)`, which answers whether the frame left the page and never whether the shell accepted it. Two hrefs the app builds today were posted, answered true and suppressed the local fallback, so the tap did nothing at all: the Connection-log link's object form, which `String` turns into `[object Object]`, and any href carrying a fragment, because the pathname is stripped to match and the whole href is what goes on the wire. Object hrefs now resolve the way the router resolves them, and the string is checked against the envelope's own pattern and cap before it is posted; anything that fails falls through to the local router, which is the policy this module already states. Whether a target names a screen that exists is shape's business no longer, and the comment says C1.7 owns it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say what was wrong with the screen a refused shell named C1.1's per-kind log lands on a branch that also refuses a route, and that diagnostic was still falling through to the line about a view outliving its host. It names the shell's own bug now, and carries the issue. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a dot segment however the route spells it A URL parser percent-decodes a path before it resolves it, so `/h/%2e%2e/x` climbed out of the `/h/` prefix exactly as `/h/../x` does and landed the page on a screen nobody asked for, with no refusal anywhere. The one segment rule both patterns share now reads the encoded spellings as the dot segments they are, and still lets an escape inside a name through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name routes in the manifest field list the builder emits C1.3 added `routes` to every manifest this builder writes, and the Phase A contract test still listed eight keys, which is what went red in CI. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold a navigate target to the same segment rule as the shell's The href pattern is built from the segment source C1.2 tightened, and nothing said so: a spelling one pattern refused while the other took it would be a hole with a `notify` already pointed at it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the ref-refresh probe the navigations this branch added C1.1's new case builds its own probe, and on this branch a probe also collects the hrefs the page hands back. The file stopped typechecking on the merge, which the tests ratchet caught. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): format the web shell route entry Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3336933cc8 |
fix(orchestration): list worker Dispatches newest first and warn when the page truncates (#21523)
* fix(orchestration): list worker Dispatches newest first and warn when the page truncates `worker-list` paged `ORDER BY d.rowid ASC` with a 100-row cap, so a Run with more than 100 Dispatches answered with its OLDEST 100. The workers a coordinator had just started, and the rows carrying `projection.attention.requiresAction`, were on a page nobody fetched, while `counts` and `page.total` covered the whole Run so the receipt read as complete. One ordering, flipped: the detail query and the terminal-state scan it pages by both order `d.rowid DESC`, and the cursor fence walks down (`d.rowid < anchor`). The snapshot fence is unchanged — `d.rowid <= snapshot` still means "nothing created after the first call". When the page truncates the receipt now carries a `warnings` string, the same shape `worker-output` already uses, alongside `page.hasMore`. Text output keeps its `More: --cursor` line and prints the warning through the block it already had for partial-host errors. Refs STA-7861 * fix(orchestration): make the worker-list truncation warning true on every page The warning said "Showing the N newest of T Dispatches" unconditionally, but `hasMore` is true on every page except the last, so page 2 of a 300-Dispatch Run claimed to be the newest 100 while showing rows 200..101. This PR exists because a receipt read as complete when it was not; that warning shipped a receipt that read as the newest page when it was not. The page count and the ordering are separate facts, so state them separately: "Showing N of T Dispatches, newest first; more are on later pages." True on page one and page N alike, no extra state. The 105-row case only ever reached the last page, where `hasMore` is false, which is why it missed this; a new case walks 6 Dispatches at `--limit 2` so a page that is truncated AND not page one is covered. Also: the `worker-list` --help note and the recovery-and-cleanup reference still described the oldest-first contract; both now say newest first. The snapshot test is renamed to the property it actually proves — under DESC a later insert is unreachable by arithmetic, so what the `d.rowid <= snapshot` fence still earns is pinned `page.total` and `counts`, not row exclusion. The continuation comment says "below the anchor" next to `d.rowid < ?`, and the two SAFETY rationales now say what they are: an unchanged cast the gate flagged because the diff moved inside its span. Refs STA-7861 |
||
|
|
73a58bd21a |
feat(session-search): resolve Workspace and Project scope on the host (#21509)
* refactor(session-search): move the AI Vault project key to shared The host must spell a project key exactly as the client does, so the two sides share one function instead of two copies that can drift. * feat(session-search): add a scope identity to the search request The panel cannot keep translating a project into one path per worktree: a repo with 580 of them exceeds the 64-path cap and the search fails outright. The request now carries the scope's identity instead, and a host acknowledges the scope it resolved so a client can tell a scoped answer from an old host's unscoped one. * feat(session-search): resolve a scope identity on the host that answers Every entry point already funnels into searchSessionService, so the identity becomes paths there once: native, WSL, SSH and relay hosts cannot disagree. A host that does not know the workspace or project answers scope-unknown rather than widening the search to everything it has. * test(session-search): pin how a host resolves a scope identity Covers prior paths, a workspace another now claims, folder workspaces, a custom worktree base path, flat placement where the global root belongs to every project, and the 580-worktree fold the panel's path list could not do. * fix(session-search): type the scope store by what the catalog reads A full Repo/Project/ProjectHostSetup requirement forced test stores to stand up rows the catalog never looks at. * feat(session-search): send the scope identity from the panel Workspace and Project name what to narrow to; All sends nothing. A host that answers a scoped search without acknowledging it is reported as needing an update, and none of its hits are shown, because they are not this scope's. * test(session-search): pin the new-client-against-old-host skew An old host strips the identity and answers with every session it has, and the answer is well-formed. The missing acknowledgement is the only evidence, so the merge drops those hits and names the host instead. * test(session-search): pin the identity and acknowledgement across every entry point IPC, the runtime RPC method, the relay handler and the shared remote client each carry the identity out and the acknowledgement back, and the relay -- which has no repo catalog -- reports the scope rather than widening the search. * fix(session-search): acknowledge the scope on an all-computers merge The merge built its results without the acknowledgement, so the renderer read it as an old host, dropped every hit and asked for an update. That is the default path: the panel defaults to Workspace and the host scope falls back to All. Per-host skew is still reported through `hosts`. Host-resolved paths no longer travel in `filters.scopePaths`. That field is capped at 64 for the clients that write it by hand, and the scanner child re-parses the request with the same schema -- so a project whose worktrees do not share one managed directory failed at 65 paths with "not ready". They ride beside the request now, where no wire cap applies. Managed directories come from buildKnownOrcaWorkspaceLayouts, so a workspace root the user has since moved away from is covered too. A workspace identity is resolved through this host's own worktree registry rather than the directory embedded in the client-supplied id. * test(session-search): follow the service search signature Host-resolved paths are a second argument now, so the call-shape assertions that pinned a one-argument call name it. * fix(session-search): answer consent and readiness before an unknown scope The registry short-circuited an unresolvable scope before current.search ran, and current.search is where disabled and not-ready are decided. A host with indexing off that lacks the project told the user it did not have the workspace, which they cannot act on. The verdict now travels to the service beside the request, and the service answers it after its own checks. * fix(session-search): acknowledge only a scope that resolved An unknown verdict is still a verdict, and it was being acknowledged as if the host had narrowed. The skipped banner also counted only 'searched' as having resolved the scope, so a host that resolved it and came back stale or timed out let the scope lines reappear where they explain nothing. * refactor(session-search): drop the version-mismatch receipt No stable release ships search, so the only hosts that have it and predate `within` are dev and ad hoc builds. The acknowledgement, the needs-update outcome and the copy behind it would be permanent dead weight from the first stable release on. The scope-unknown outcome and the off / not-ready / unknown ordering stay. Also trims this PR's new docblocks to the repo's one-line why rule. |
||
|
|
5c2d3322c1 |
fix(runtime): name a terminal whose pane a graph republish dropped (#19860)
* fix(runtime): name a terminal whose pane the graph dropped `buildPtyTerminalSummary` decided `orphaned` from the PTY record's agreement with itself — `!pty.tabId || !pane || pane.tabId !== pty.tabId`. A record whose `paneKey` still parses to its own `tabId` passes that forever, including long after the session graph dropped the pane, so a terminal that had lost its surface reported `orphaned: false, connected: true, writable: true` and a `tabId` no tab has: field-for-field identical to a healthy one (#18191). Consult the leaf topology instead, gated on a graph statement having had the standing to contradict the record. `graphSequence` counts authoritative graph statements; every statement re-records the surface of every pane it publishes, so a pane the current graph holds carries the current stamp and is answered without touching the leaf map. That covers the two absences that are not evidence, without a second flag: a surface recorded since the last statement (spawn records the pane before the graph carrying it arrives, #7587), and a lost graph clearing every leaf at once without advancing the sequence. A pane already observed dropped keeps its stale stamp and stays named, because losing the ability to re-check is not a reason to un-see it. `orphaned: true` is shipped vocabulary that both consumers already read, so no capability gate is needed: adoption keys on it (`hasStrongOrphanIdentity`) and now reaches this population, and the duplicate-surface index (`indexLiveTerminalSurfaceOwners`) stops recording a destroyed pane as a PTY's live owner. * fix(runtime): publish a terminal retirement proof on the exit's own evidence A paired client may drop a mirrored terminal on exactly two kinds of host evidence: a `retiredTerminalSurfaces` proof naming the handle, or two authoritative `terminal.list` inventories that omit it. The second needs two host publications, and a quiet workspace publishes one, so the proof is the only evidence that rides the frame carrying the retraction. That proof was minted only as a byproduct of persistence *accepting a change*, which made one value carry two meanings: "a change was accepted" and "the PTY exited". The host renderer's close transaction de-persists the surface and republishes without it, so when it got there first the exit found nothing left to accept and the attestation died with it. Measured on a real paired client: the host retracted in under 500ms, published no proof, then froze its snapshotVersion for 60s while the client kept a dead pane in its tab bar. Persistence still gates *removal* — publishing absence before the membership fence is durable would let a crash resurrect the surface. It no longer gates the proof: the observed exit is itself the attestation. The exit-first ordering already had a passing test; the renderer-first ordering had none, and that is the one users hit. Both orderings are now pinned, with exit-first as the control that makes the renderer-first failures mean something. Wire: `retiredTerminalSurfaces` is an existing optional field on an existing path, already negotiated as `session-tabs.retirement-proof-delta.v1`. This is Rule 1 — an old client that ignores it degrades to the two-inventory route it already uses today, so no capability gate is needed. The sentence "the host starts sending a frame it did not send before" reads like Rule 3; it is not, because the frame shape, the field, and the reader contract are all unchanged. * test(runtime): pin the removal frame retiring a still-live publisher KNOWN RED (`it.fails`), no product change. Found while verifying the close retraction fix: once the emptying actually reaches paired clients — a state the previous behaviour never allowed, because nothing propagated — re-adoption of a later create is flaky. Measured 1 failure in 6 runs of the two-client journey. `decideWebSessionTabsSnapshot` treats the host's synthetic `removed:<t>` retraction as a publisher handover: it retires the still-live renderer epoch and installs the retraction as current, while the removal also clears the live freshness record. The next frame from that same running publisher then matches no lineage and reads as a retired generation, so it is outranked and the publisher is locked out of the worktree until its generation changes. `local-structured-session-tabs-sync/snapshot-apply.ts` documents this exact scenario and has a revive escape; the mirror path has none. The suffix case explains the 1-in-6: `hasRetiredValue` is an exact string match, so a republication carrying `:headless-merge:` walks past the fence and only a bare same-epoch republication is locked out. Not fixed here on purpose. Dropping the retirement makes the red case pass but breaks `web-session-tabs-sync.test.ts > keeps a removed worktree fenced against delayed predecessor epochs`, which asserts a same-epoch higher-version frame after a removal must be rejected. At this layer those are the same frame — this function holds no `receivedFrame`, so it cannot separate a delayed predecessor from the live publisher speaking again. The fix belongs in `shouldApplyRecoveredWebSessionTabsSnapshot`, which does hold that ordering and currently defers to the same epoch fence. That is a contract change across two functions and an existing invariant, not a one-liner. * fix(runtime): a removal retraction is not a publisher handover The host drops a worktree's entry when its last tab closes and announces it with a synthetic `removed:<t>` epoch. Both receipt sites treated that as a publication: `decideWebSessionTabsSnapshot` and `recordReceivedWebSessionTabsSnapshot` each noted the retraction epoch as current, which pushed the still-live renderer epoch onto `retired`. The removal also drops the live freshness record, so the next frame from that same running publisher matched no lineage, read as a retired generation, and was outranked. The live publisher was locked out of its own worktree until its generation changed. That is fail-closed, and it is why re-adoption after an emptying was flaky once the emptying actually reached paired clients. A retraction and the live publisher's next frame are the same epoch at a higher version, so epoch identity cannot separate them and never could. Delivery order can. `recordReceivedWebSessionTabsRemoval` now records the retraction as the worktree's newest received evidence instead of deleting the ledger, so `shouldApplyRecoveredWebSessionTabsSnapshot` — the gate every production apply path passes before `decideWebSessionTabsSnapshot` — fences a frame that reserved its received frame before the retraction while admitting one that arrives after it. The boundary carries the retraction's own epoch, which never matches a host publication, so a later live frame may still restart its version counter. `local-structured-session-tabs-sync/snapshot-apply.ts` documents the same conclusion for the local path: a retired epoch is not proof of a dead generation. `keeps a removed worktree fenced against delayed predecessor epochs` pinned the delayed predecessor at the raw decision layer, which is the same call as the live publisher's republication. It now pins the identical scenario — same epoch, higher version, still rejected — through the receive-and-apply path that actually holds the ordering, plus the composed gate as production spells it. The committed `it.fails` repro is not sufficient on its own: it records no received frame, so dropping only the `decideWebSessionTabsSnapshot` retirement turns it green while the publisher stays locked out on every real path. A receive-and-apply case is added alongside it to close that gap. * test(runtime): pin the retraction boundary against a stale inventory omission Mutation testing left a survivor: writing the boundary unconditionally, instead of only when it advances the ledger, passed the whole runtime suite. It is not inert. A visibility-resume inventory reserves its received frame before it lists, so an omission it reports can be older than a stream frame that landed meanwhile; without the guard that stale omission rewinds the ledger, forgetting the stream frame's version, and a delayed list reserved in between is then readmitted instead of outranked. This pins that ordering. The one remaining survivor is the boundary's `snapshotVersion`, and it is inert: the ledger's version is read at exactly two sites, both reachable only when the incoming frame's epoch equals the stored one, and a retraction epoch never equals a live publication. * test(runtime): cover the fences the retraction change narrowed Two gaps found by mutating the fences themselves rather than the fix. Deleting the epoch fence in `shouldApplyRecoveredWebSessionTabsSnapshot` passed the entire runtime suite. It is not unreachable: a superseded generation whose sibling stream delivers its frame after the handover outranks the successor on delivery order, and only the retired-epoch check rejects it. Retractions used to exercise that fence too; now that they no longer retire anything, a genuine handover is the only thing left that reaches it, and nothing covered that. The fence is narrower than it was, not dead. The second case pins rate-independence. The defect surfaced 1 run in 6 because `hasRetiredValue` is an exact string match while `sameSessionTabsPublicationLineage` treats `:headless-merge:` as the same publisher, so a merged republication walked past a fence a bare one hit. The removal path is now asserted over both epoch shapes through the full path, so a fix that only re-rated the defect instead of removing it would fail here. * fix(runtime): give "same publisher" one answer across the epoch fences Separable from the retraction fix beneath it, and it changes handover-path behaviour: a superseded generation that republishes under a merged epoch is now rejected where it was previously accepted. Take it independently or not at all. `publisher-identity-fences.ts` held two answers to "is this the same publisher". `noteRetiredValue` treated a `:headless-merge:` epoch as a SUCCESSOR of its base and retired the base when the merged form became current, while `sameSessionTabsPublicationLineage` treated the two as ONE publisher. Those are contradictory, and the retired-value check's exact-string match was the shim that kept them from ever meeting: a merged frame was a different string, so it never looked retired no matter what had been retired. The cost was that the same predecessor was accepted or rejected depending on which shape it arrived in. A generation a successor had replaced was fenced when it republished bare and admitted when it republished merged — the fail-open half of the same disagreement whose fail-closed half was the removal defect, and the reason that defect reproduced 1 run in 6 rather than every time. This cannot be fixed in the fence alone. Making the fence lineage-aware while a merged epoch still retires its base has the generation retire itself: the rebuild arrives, retires its own base, and the fence then rejects it as a retired generation. So both sides move together — a lineage sibling advances the current epoch instead of superseding it, and inherits its generation's retirement instead of escaping it. Scoped to the publication-epoch functions. Runtime-id retirement keeps exact matching, and `local-structured-session-tabs-sync` keeps its own `hasRetiredValue` call, where a lineage sibling is already excused explicitly and a retired epoch is deliberately not treated as proof of a dead generation. * test(e2e): journeys for a reopened client and two clients on one host Two gaps this suite had no coverage for, both driven end to end against a real paired desktop client rather than at a seam. A relaunched client holding a live remote terminal: every paired restart spec here restarts around a browser pane, none around the terminal the user is actually mid-work in. The host-side fixture's on-disk sink is the oracle — one READY for the whole run proves the host never re-spawned the session, and a recorded line for input sent after the relaunch proves the restored pane is wired to that same process rather than painted with its scrollback. Two clients on one host across an emptied workspace: the tombstone is client-local on the runtime path, so a client that never held a row still seeds into a workspace another client deliberately emptied. That asymmetry is by design; a client falling out of step with the host and staying there is not. Phase 0 is the control — without it a later divergence cannot be attributed to the emptying rather than to mirroring never having worked. The input probe goes through `pane.terminal.input`, not `window.api.pty.write`: a mirrored pane's handle is a `remote:` id that no local PTY answers to, so a direct write is swallowed and the assertion passes on nothing. The pre-restart control exists to catch exactly that, and did. * test(e2e): keep the two-client journey spec type-clean * test(e2e): pin the close retraction a paired host does not publish * docs(e2e): say why the red close-retraction spec sits on this PR The spec was written on a branch carrying neither of this PR's publish-side fixes, and its own diagnosis -- the fault is the host's publish-after-close, not any client's mirror -- names exactly what they change. Landing it here makes CI the measurement rather than leaving a red spec parked on a branch with no fix in it. Records the one thing a reader needs to not do: skip-tagging it. And why the obvious split is not a block move -- phase 2 depends on phase 1b's emptying and both share the two-client pairing fixture, so splitting means duplicating the fixture. * test(e2e): the close-retraction spec is green on this branch, measured It was written to pin a defect and was red where it was written. On this branch, with `publish a terminal retirement proof on the exit's own evidence` and `a removal retraction is not a publisher handover` both present, it passes -- twice, independently: phase1a A=9ms/B=158ms then A=2ms/B=1ms, against a prior baseline of "none reached either client within 90 seconds". So the KNOWN RED header had become the thing it warned about: a test carrying prose asserting the very behaviour the commits beside it remove. Rewritten to record the measurement and the numbers to regress against, and to keep the one instruction that still applies -- if it reddens again, do not skip-tag it; the failure shape is a 90s timeout on both clients at once while creates still propagate. No assertion changed. Comment only. * test(wire): pair the session-tabs retirement proof across two builds The stack makes a host start sending a retirement proof on its own frame when no surface removal carries one. The change argues Rule 1; Rule 3's fourth bullet covers a frame the host starts sending on an existing path, so the claim is measured against v1.4.199 rather than accepted. Neither existing cross-version suite reaches session-tabs: the terminal one covers the binary stream, the agent-session one covers agentSession.*. Result: the old client acts on the proof-only frame, because the whole client half of this surface is unchanged. The old-host cells are pinned to a release that cannot publish the frame at all, which is what makes the new-host cells mean something. * fix(lint): clear the casting gate on the surface-lost inventory main tightened typescript/consistent-type-assertions to assertionStyle: never, which the rebase brings onto these added lines. The retraction read narrows on the property instead of casting; the fixture and cross-build-import casts carry per-site SAFETY rationales. * fix(lint): bind the protected-stamp cast to a name The leading-semicolon parenthesised call put the suppression on a line oxfmt then reflowed away from the assertion it covers. Naming the narrowed handle keeps the directive next to the cast. * fix(runtime): route every non-null surface write through the stamped writer `ptyHoldsRecordedSurface` trusts a record only while its stamp is current; after that the leaf map answers. Four writers still named a pane with a bare `tabId = / paneKey =` — orphan adoption (both branches), split, create on an adopted stable pane, and TUI-owner recovery — so a record that had already been contradicted stayed contradicted after the claim, and `terminal list` reported the just-claimed PTY `orphaned: true` until the renderer's next graph statement re-recorded it. Before this branch those sites read as attached at once, so this was a regression window of one round-trip, and `indexLiveTerminalSurfaceOwners` reads `orphaned` as "unowned". `recordPtySurface` is now the one writer; the adoption module reaches it through a port because it has no `graphSequence` of its own. The nulling writers are untouched: a null surface is never held, stamped or not. * test(runtime): keep one copy of each publisher-fence case The removed-frame suite asserted four properties that another case in the same suite or the lineage suite already pinned: - the decide-only readmit and the bare full-path readmit are the bare arm of the parameterized full-path readmit, verbatim; - the merged-suffix decide-only readmit is the merged arm of the same loop; - "still fences a predecessor a successor replaced" is the lineage suite's bare arm with different version numbers; - the recovery-gate handover case is the lineage suite's recovery-gate case with a bare late frame instead of a merged one, so that test now runs both shapes and this copy goes. Mutation-checked: reverting each of the five renderer changes on this branch (retire-on-removal in decide, noting a retraction current, the exact-match retired fence, merge-supersedes-base, dropping the ledger on removal) still fails at least one of the remaining ten cases. Also corrects the suite header: a retraction carries a synthetic `removed:` epoch, so it is the in-flight predecessor frame, not the retraction, that shares the live publisher's epoch and needs delivery order to be separated. * test(e2e): fail the two-client journey when phase 1a cannot run Phase 1a sat inside `if (beforePartialClose.length > 1)`. A host workspace that starts with one terminal skipped the control silently while 1b and 2 still ran, and the spec passed green without ever exercising the close-with-others-open retraction it was written to measure. The skip is now a recorded failure naming the host count. * fix(runtime): order every session-tabs apply path against the retraction A closed terminal came back on the other client because "this worktree was retracted" was neither durable nor universal: - `refreshWebRuntimeSessionTabsSnapshot` reached `decide` with no place in receipt order at all, so a list the host answered before the close applied after the retraction had already cleared the worktree. It is a production path for close, create, activation, split and PTY reconnect. - the boundary lived in a single receipt slot the next stream frame overwrote, and in a fence that only existed when a recovery happened to be pending when the retraction landed, so a pre-close list could out-rank the republication on `snapshotVersion` alone. Replace both with one raise-only removal watermark per (environment, worktree) and give the list path a receipt position, reserved by the request and carried in its answer so a dedupe joiner inherits it rather than minting a newer one. The pending-recovery fence and its bookkeeping are dead once the boundary is monotonic. The exact-match retirement check in the receipt ledger becomes the one lineage-aware predicate, so a `:headless-merge:` rebuild can no longer be noted as current and retire the live publisher out of its own worktree. On the main side, `recordPtyWorktree` stamped `surfaceRecordedAtGraphSequence` at write time, so any `paneKey` write claimed the standing of a fresh graph statement. The inventory restore in `terminal list` therefore un-dropped the very pane the read was meant to report, on every listing. A surface claim now carries no graph standing unless its writer names one: the graph statement, live leaf output and spawn do, while the inventory restore, the floating liveness restore and the mobile projection replay do not. Defaulting this way means a writer that says nothing fails safe and self-corrects, which the type alone could not guarantee across the projection contract's own `recordPty`. Spawn claims now span the one graph statement the renderer may already have in flight, and retirement proofs compare by identity instead of by position, so a re-delivered exit no longer fans out a `snapshotVersion` bump carrying nothing. * fix(runtime): stop an unpublished-worktree placeholder retiring the live publisher A worktree the host has published nothing for still answers a forced list, with a synthesized `none`/v0 frame that means "ask me later" (host-session-snapshot-authority.ts). Every post-close list and every activation of an emptied worktree gets one. Noting it as a publication retired the renderer generation that is still live, and because that epoch is per-process, the terminal the user created next never reached this client — the same lockout the retraction path was already careful to avoid, through a door it did not cover. `local-structured-session-tabs-sync` already skips the placeholder for this exact reason; the web mirror now does too, on both the receipt ledger and the frame decision. Bound the receipt ledgers by frame age rather than entry count. One bootstrap inventory records a receipt per worktree under a single reserved frame, so evicting by insertion order dropped that batch's own earlier entries, and an absent receipt is what the recovery gate reads as "no evidence for this worktree". Only a receipt no in-flight frame can still be ranked against is droppable. Take the receipt gate off the `web-session-tabs-sync` barrel in the refresh path. Ordering is that path's gate, not an optional collaborator a caller's module mock may leave out, and being reachable only through the barrel is how the path came to have no ordering at all. * fix(runtime): let the TUI-owner recovery name its pane without claiming the graph holds it `recoverStructuredTuiOwner` rebinds a recovered PTY from the persisted owner binding — the same replayed-evidence class as the inventory restore — but stamped it with the current graph sequence, so a pane the renderer had already dropped read as attached for one more statement. The guard below it needs the tabId and paneKey, not the standing. Also say plainly in `decideWebSessionTabsSnapshot` what the affirms check does and does not cover: an unpublished-worktree placeholder is withheld from epoch noting only. It still applies, because rejecting it outright would drop the terminal reconciliation that legitimately rides on it. * fix(runtime): keep the retraction boundary out of the receipt bound Bounding the removal watermark alongside the receipt ledger reintroduced the defect the watermark exists to prevent: past 512 retracted worktrees, evicting a boundary readmits every pre-close frame it was fencing, and a delayed list resurrects the closed tab. A boundary is not a cache. One number per worktree ever retracted on an environment is the cheaper price, and environment teardown drains it; only the receipt ledger stays bounded, by frame age. Split the orphan-adoption port by provenance so the last writer that disagreed with the surface-standing rule stops disagreeing. `adoptRuntimeTerminalOrphans` replays the persisted binding when the claim already matches it and writes a new one otherwise, and both went through a single `recordSurface` that stamped the current graph sequence — so re-adopting an already-adopted orphan lifted a dropped pane's stale stamp and reported it attached, in a quiet workspace possibly forever. The replay now names the pane without standing and the fresh claim takes spawn standing, like every other writer. Replace a receipt-count assertion that was vacuous for a map keyed by environment and worktree with the mirror state and freshness it was standing in for. * fix(runtime): keep a closed-tab worktree under the epoch already publishing it `closeHeadlessMobileTerminalTab` minted `headless:<now>` on every close. Its sibling headless writers carry the stored `publicationEpoch` forward and mint only when there is no snapshot to inherit from — because a write to a worktree is not a claim to publish it. The close was the one writer that claimed. A paired client retires the epoch a new publisher displaces, and the web mirror's retirement is final: there is no revive lane, and the per-worktree tracking teardown deliberately keeps the epoch history. So an ordinary close published a stranger for a worktree the renderer generation still owned, retired that generation on every client, and the renderer's next publication — carrying the epoch the close had just retired — was rejected forever. The user emptied a workspace, created a terminal, and it never arrived on either machine while `session.tabs.list` showed the host holding it. This is the same thesis the retraction path already states, through the door next to it: a retraction is not a handover, and neither is a close. Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each: phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s budget) and 0/6 after, with both clients adopting in single-digit milliseconds. * fix(lint): give the fixtures real types instead of casting past them The casting gate failed on eight assertions this branch added. All eight were suppressible, but the suppressions were not the problem: the casts were hiding fixtures that did not match the contracts they stood in for. `sessionStillHoldingBothPanes` built tabs as `{id, title, type}` — `type` is not a `TerminalTab` field and eight required ones were missing — and layouts holding only `ptyIdsByLeafId`. `as never` made both compile. They are now real `TerminalTab` / `TerminalLayoutSnapshot` values, so the fixture is checked against the type `listTerminals` actually reads. `terminalTab` in the epoch suite built a *client* tab (`status`, `terminal`) for a field typed with *snapshot* tabs, which forced `as never` at the call and a cast on the snapshot itself. Production reads only `type`, `parentTabId`, `leafId`, `ptyId` and `parentLayout` from that tab, so the two client-only fields were inert; dropping them lets the declared `RuntimeMobileSessionTerminalTab` type the fixture end to end, and the closed tab is now held by name rather than recovered from `snapshot.tabs[0]`. The remaining three casts are unchanged in kind and now carry correctly placed SAFETY rationales: reaching a protected member is the only way to drive these paths. `graphSequence` folds into the reach-through that was already there rather than opening a second one, and the map read narrows instead of asserting. Mutation-tested, all three suites, regression re-introduced for each: - epoch mint on close restored -> 1 failed | 1 passed - orphan check reverted to self-consistency -> 4 failed | 4 passed - placeholder retirement guard removed -> 1 failed | 7 passed src/main/runtime 8169 passed | 31 skipped; src/renderer/src/runtime 1581 passed. `check:code-quality:changed` goes 8 findings -> 0. `pnpm tc` clean. * fix(runtime): stop the headless placeholder graph from dropping every restored pane A headless server publishes one empty graph at launch so status clients see a ready server. It names no renderer pane and is never replaced, but it was counted as an authoritative graph statement all the same: `graphSequence` went 0 -> 1 while the leaf map stayed empty for the life of the process. Every surface claim written without standing - a persisted replay, an inventory restore, the TUI-owner recovery - is stamped 0. Against `graphSequence` 1 the `>=` guard fails, the empty leaf map answers "no pane holds this", and the terminal reports `orphaned: true` under a `pty:` tabId. Nothing can re-stamp it, because the only graph that host will ever publish has already been published. On a headless or SSH host that is permanent, and it is the same lie #18191 is about, pointed the other way. The placeholder no longer spends a graph statement. A renderer graph still does, so a pane a real graph drops is still reported dropped - including on a desktop window promoted from headless, which the third case pins as a negative control. Mutation: restoring the unconditional bump fails the first two cases ("expected 1 to be +0", "expected true to be false"); the promoted-window control passes either way, as a control should. Also registers tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts in the cross-version-wire job. The file matches CROSS_VERSION_WIRE_PREFIXES, so adding it had switched the job's gate on, but the job runs an explicit file list that omitted it - the test executed nowhere in CI. It passes 8/8. |
||
|
|
01a33bc427 |
fix(git): respect existing .orca ignore rules
Respects effective local, WSL, linked-worktree, runtime, and SSH Git ignore rules before updating .gitignore. Fixes #21212. |
||
|
|
9907117569 |
feat(native-chat): record an explicit provider outcome on every structured turn (#21278)
A structured turn that FAILED was recorded as `completed`, identically to one that succeeded, so nothing downstream could tell them apart. Claude mapped only its two abort reasons to `interrupted` and let an API error fall through to `completed`; Codex collapsed every non-`completed` status to `interrupted` and read a missing status as a clean finish. Add `outcome` — success / failure / cancellation — to the turn record, emitted by both providers. The four-arm lifecycle union is deliberately untouched: it stays a report on what the HOST observed, and its readers are unaffected by construction. Absent means UNKNOWN and never success. Historical rows, older hosts, and any end the host inferred rather than heard (the child going away, a turn superseded before its result) all carry no outcome, so a newer client cannot mistake an old host's `completed` API error for a clean turn. Claude's abort-reason list had a second copy in the provider-fallback reader; both now classify through one `claudeResultOutcome`, so the durable verdict and the visible error row cannot drift. |
||
|
|
b90837ee46 |
feat(mobile-web-bundle): advertise the bundle capability where a bundle ships (OTA phase A, 4/5) (#21376)
* feat(mobile-web-bundle): advertise the bundle capability where one ships status.get pushes mobileWeb.bundle.v1 only when the install's bundle resolves and its manifest parses, beside the other conditional capabilities. Dev trees and `orca serve` installs may carry no out/mobile-web, and a static entry there would promise a download that only ever answers mobile_web_bundle_unavailable. No protocol version bump: protocol-version.ts asks for one when a method or a required field is removed or changes meaning, not when a capability is added. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that mobileWeb.bundle.v1 is inert on a released client Derives the old desktop's reply by removing the one capability from what the new one sends, rather than writing down what the old client had, and asserts every released read of status.get lands identically apart from that string: the gate hook, the three transport readers, the quick-command predicate and the worktree-create support probe. Proved red against three mutants: a closed enum on the capability schema (the salvaged field drops whole, so nothing publishes), a client-side filter over the new name, and a gate that changes floatingWorkspaceEnabled when it sees it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): name the invariant behind the fake client's cast The changed-code casting gate wants the rationale on the line, and the reason is narrow enough to state: every reader under test reaches the client through an rpc operation's `request`, which uses sendRequest alone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
9641a1b544 |
feat(mobile-web-bundle): serve the packaged mobile web bundle over RPC (OTA phase A, 3/5) (#21348)
* feat(mobile-web-bundle): serve the bundle manifest and chunks over RPC Two paired-runtime methods on the already-authenticated connection: `mobileWeb.bundle.manifest` returns this install's manifest plus the chunk size it advertises, and `mobileWeb.bundle.chunk` returns one aligned range of one asset with the whole asset's length and hash, so a single chunk describes what it belongs to. `path` is accepted only by exact match against a manifest member, so traversal is unreachable rather than mitigated. Each asset's on-disk sha256 is verified once and the verdict remembered, concurrent first readers sharing one hash. Reads are capped at four in flight per connection, and a disconnected client stops costing reads at the next checkpoint. No SSH or relay proxying: a runtime answers only out of its own install. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the three buildId serializers against each other The canonical serialization exists in the builder, the packaging guard, and the shared contract, because the two packaging scripts run on bare node before any build output exists and cannot import TypeScript. A divergence in any one would reject every honest bundle at packaging, or ship a bundle whose id the phone recomputes differently and re-downloads forever. Proved red by swapping the guard's code-unit sort for localeCompare: five of six cases fail. Exports the guard's serializer for the test; no packaging behaviour changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover every error code and a multi-chunk paging round trip Against a synthetic bundle in a temp dir, because the real builder's largest asset is under one chunk and CI unit jobs never build out/mobile-web. The fixture's script spans three chunks, its stylesheet is exactly one, and one asset is empty, so paging, the eof boundary, and the zero-byte case are exercised rather than assumed. Reads in flight are held by latching `open`, so the four-per-connection cap and an abort arriving mid-read are deterministic rather than a race with a stopwatch. Both were proved red: dropping the abort check after verification fails the abort case, and keying the cap on connectionId alone fails the device-token case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): resolve the bundle root through the AppEnvironment port check:runtime-electron-ratchet caught this: the resolver sat beside getBundledWebClientRoot in src/main/startup and imported electron, and importing it from an RPC method pulled the first electron edge into a runtime graph whose baseline is zero. The runtime has to stay bootable on plain Node. So it reads app.getAppPath() through the port every other runtime module already uses, and moves next to its two callers under src/main/runtime. A host with no environment installed has no install root, which is the same answer as having no bundle. orcad answers getAppPath from its own install root, so a headless runtime that carries the artifact serves it with no special case. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): cover the resolver's two probe layouts directly Also stops exporting the manifest filename, which nothing outside the resolver needs. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin both methods on the mobile allowlist The scanner only checks mobile-used ⊆ allowlist, and no mobile source calls these until A5, so deleting both entries left every test green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): keep filesystem failures inside the six error codes An asset unlinked or truncated after its verdict was cached reached the client as runtime_error carrying the desktop's absolute install path. Both now answer mobile_web_bundle_asset_changed, with the cause warned host-side only. A short positional read is the truncation case, so it throws instead of paging the client past the end. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): drop the unreachable release-idempotence guard The one caller releases exactly once in a finally; removing the flag left every test green, so it was defensiveness against a caller that does not exist. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): prove a failed verify is not cached as a verdict The verdict cache never invalidates, so a transient read failure remembered as a verdict would poison the asset for the life of the process. Removing the delete left every test green until now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): delete the unsatisfiable manifest params schema The dispatcher substitutes `{}` for absent params, so `z.null()` could never parse; the method declares `params: null` instead. A comment on the method name records why there is no schema. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web-bundle): fill the read window instead of failing a partial read fs.read may answer short of what it was asked for before EOF, so the previous check turned a legitimate partial read into a spurious asset_changed. The loop mirrors the relay's readFullStreamChunk, which is not imported because it sits behind the relay dispatcher's module graph; only a read returning nothing is treated as truncation. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web-bundle): read the disconnect idiom with the shared predicate isClientDisconnectedError already exports exactly the check the catch needed, so the local error class goes away and the throw returns to the repo-wide idiom. The module doc now says asContractError is a total catch. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile-web-bundle): pin the four branches no test was holding Each one survived a mutation: the abort check before verification, the per-process manifest cache, the buildId component of the verdict key, and delete-at-zero in the admission map. The last two matter beyond hygiene — a verdict keyed by path alone carries a failed verdict onto the next build of index.html, and a map that never drops a key retains one pairing token per socket. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb |
||
|
|
3de77340fc |
fix: apply managed Claude auth to Agent Teams (#21356)
* fix: apply managed Claude auth to agent teams * test: update agent teams auth launch expectation * refactor: derive agent teams auth deletions |
||
|
|
691d9692e6 |
fix(pty): stop detached OMP tools on immediate terminal close (#20642)
* test(omp): add opt-in owned PTY closure probe * fix(pty): sweep detached tools on immediate unrecognized shell close * test(omp): create close probe evidence root in fresh worktrees * test(pty): account for asynchronous immediate descendant cleanup * test(pty): reject inconclusive descendant cleanup probes |
||
|
|
d04b05b5c8 |
Detach retained CI and terminal tails from oversized strings (#20960)
* fix(memory): detach retained CI and terminal tails from oversized strings * fix(terminal): detach retained error and reattach string slices * fix(terminal): release oversized recent-output backing strings * fix(terminal): release backing strings held by PTY detectors * fix(memory): own bounded Claude background task labels * fix: detach retained terminal mode scan tails * fix: own retained plugin worker output strings * fix: own incomplete OSC 133 carry strings --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> |
||
|
|
1aadf91153 |
fix(runtime): preserve observed exit during explicit terminal close (#21019)
* fix(pty): reconcile daemon exits after synthetic notifications * fix(runtime): preserve observed exit during explicit terminal close --------- Co-authored-by: m4air <m4air@Mac.localdomain> |