From ee7ec43149e33d8fc12bd905670a132042801fe0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:47:07 -0700 Subject: [PATCH 01/53] fix(codex): keep a host account switch inside the host lane (#10992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): keep a host account switch inside the host lane markLiveCodexSessionsForRestart walked every tab's PTYs and carded any pane whose foreground looked like Codex. There was no lane check anywhere in that path, so a host account switch raised a restart notice on live SSH/relay panes — and a notice mutes the pane, so the user's remote terminal went deaf. The notice was provably spurious: a remote spawn carries a connectionId, so isDaemonHostSpawn is false and no CODEX_HOME is ever injected. The remote Codex uses the remote machine's own credentials; a local selection cannot reach it. Scope marking by lane instead. A pane's lane is (machine, runtime): `host`, `wsl:`, `env:` for a relay environment, or an SSH connection that no managed selection can name. A switch made while a runtime environment is active still cards that environment's panes, which is the case that made the old "mark everything" behaviour look right. WSL was the same defect, not a separate one. A Windows run saw a WSL pane correctly escape a host switch, but only because its foreground read `wsl.exe`, which fails the Codex-foreground test — the Win32 process table cannot see into a WSL2 VM. That is incidental: `codex`, `node` and `python3` foregrounds are all eligible today, so a WSL pane that surfaces one (WSL1 pico-processes are in Win32_Process) would be carded by a host switch. The lane is now what decides. Also stop queueing remote and SSH panes into the bind-driven stale sweep at all. recordCodexPaneAccountForSpawn bails on anything that is not a daemon host spawn, so listStalePanes can never report one stale, yet each pane still spent every rung on a 15s-timeout remote RPC — ~75s per pane since the ladder widened to five rungs. The lane vocabulary moves to shared/ so the renderer keys panes exactly as a launch does rather than growing a third copy of the rules. Refs #10757 * fix(codex): key a WSL pane by the distro its launch actually used The lane guard derived a pane's WSL distro from the workspace UNC path alone. A launch does not: pty.ts hands getCodexSelectionTargetForPty a third argument, the resolved runtime's distro, so a wsl.exe pane on an ordinary Windows-path worktree launches under `wsl:Ubuntu`. The renderer keyed that same pane `wsl:__default__`, so the Ubuntu switch never reached it — the pane kept the old account with no notice, which is #10757 returning by a new route on the exact platform the issue was reported from. Resolve the distro the way the spawn does: the project execution runtime first, then terminalWindowsWslDistro. Both are already in renderer state. Also match a distro-less WSL switch against the whole `wsl:` family. Two mutations reach the renderer as `{runtime:'wsl', wslDistro:null}` while writing concrete distro slots: selecting the system default clears EVERY wsl slot (setSelectedCodexAccountIdForTarget), and `add` stores the distro it discovered from the machine. Keying those to `__default__` missed the very panes they re-pointed. The residual cost is over-marking a sibling distro after an add, bounded to this machine's WSL panes and far cheaper than a stranded pane. An owner-less remote pane colliding with the host lane was untested — that collision is what would mute a working remote terminal, so pin the disjointness rather than the literal key. Refs #10757 * fix(codex): resolve a pane's lane the way its launch resolved it Three more places where the renderer's lane and the launch's lane disagreed. Each disagreement is silent: too narrow and a stranded pane never gets its notice (#10757 returns), too wide and a healthy pane is muted, because a notice makes onData drop every keystroke. Shell: main runs the request through resolveLocalWindowsTerminalRuntimeOptions, so an unset shellOverride still lands on WSL when that is the Windows default. Reading tab.shellOverride alone called such a pane `host` — a host switch would have muted a working WSL terminal. Gate on the renderer platform, as pty.ts gates on process.platform. Cwd: a terminal's startup cwd is deliberately not constrained to the worktree (resolveTerminalStartupCwd, #7685), and main keys the lane off that cwd. Follow it through the same shared call instead of reading the workspace root, so a pane split after `cd \\wsl.localhost\...` is keyed where it actually runs. The comment claiming a pane can never start outside its workspace was simply wrong. Family match: narrow the previous commit. setSelectedCodexAccountIdForTarget only nulls every WSL slot when the account is null AND no distro is named; any other write lands in one slot. So claim the family only when the change actually cleared them all, and let `add` pass the created account's concrete target rather than the row's "WSL default". Both call sites already knew which case they were in. Refs #10757 * fix(codex): derive the pane's project runtime the way main does The previous commit reached for getLocalProjectExecutionRuntimeContext as a stand-in for main's resolveLocalProjectRuntimeForWorktreeId. They are not the same function, and the differences both produce wrong lanes: - It falls back to `state.activeRepoId` when the worktree is not a git worktree, so a folder-workspace pane inherited whichever repo happened to be selected. That is not a property of the pane at all — the lane moved when the sidebar selection moved. On a WSL project it both muted a healthy host pane and hid the notice a host switch owed it. - It synthesizes a runtime from `inherit-global` where main returns undefined, and its host branch rewrites an explicit `wsl.exe` to powershell.exe, keying a live WSL pane `host`. Walk repo -> project directly instead, which is what resolveLocalProjectRuntimeForRepo does, and use it only to supply a distro — never to downgrade a shell. That also drops the throwing call out of this path entirely; the lane runs outside scanCodexPanes' inspection guard, so a throw there would have lost the notice for every pane in the batch, not just one. Also find the added account by diffing the roster. Reading it back through the row's active id returns null once two distro slots are filled, which sent the notice to `wsl:__default__` while `add` had written a concrete distro. Refs #10757 * fix(codex): key the lane off the runtime the renderer actually shipped Reverses the project-runtime half of the previous commit. That commit assumed main resolved the project runtime itself, so it re-derived one by hand. It does not: for a local pane the RENDERER computes it with getLocalProjectExecutionRuntimeContext and ships it with the spawn (pty-connection.ts), and pty.ts feeds that straight to getCodexSelectionTargetForPty. So the helper is not an approximation to be improved on — it is the launch. The hand walk dropped the global Windows runtime default, which is what turns an `inherit-global` project preference into WSL. A user who set their runtime default to WSL but left terminalWindowsShell alone would have had every live WSL pane keyed `host`: muted by a host switch, and missed by their own. It also disagreed on folder workspaces, where the launch really does resolve through the active repo. Keep the repair-required early return: that call throws, and it sits outside the scan's per-pane failure guard, so a throw would lose the notice for every pane in the batch rather than one. Separately, floating terminals have no workspace root, so their startup cwd is used verbatim (resolveTerminalStartupCwdForWorkspace). Resolving one against a root that does not exist yielded no cwd at all, keying a floating Codex pane on a WSL filesystem as `host`. Read its cwd directly. Require exactly one new account before trusting the roster diff — an unloaded prior roster makes every account look new, and Add Account is not gated on it. Refs #10757 * fix(codex): stop claiming a floating-terminal cwd the tab never has The floating-terminal branch read tab.startupCwd, which no floating creation path ever sets (FloatingTerminalPanel, FloatingTerminalWindowControls, floating-workspace-tab-creation all pass none). Its cwd is resolved over IPC from settings.floatingTerminalCwd and handed to the transport as a prop, so it never reaches the store at all. The branch was inert and its comment described main's handling of args.cwd rather than what the code read. Say what is actually true: a floating pane is keyed by its shell, and the configured-WSL-cwd-under-a-host-shell case is a known gap. Guessing from the unresolved setting would risk the mute direction, which is the expensive one. Also pin the repair-required early return. resolveLocalWindowsTerminalRuntimeOptions throws there, and the lane runs outside scanCodexPanes' per-pane failure guard, so without it Promise.all rejects and every pane in the batch loses its notice. That guard had no coverage; removing it now fails with the spawn error. Refs #10757 * fix(codex): trust the lane main recorded at spawn over a re-derived one The switch path re-derived each pane's Codex lane from current state while main had already written the resolved shell, cwd and distro at spawn. Four review rounds each found another divergence between the two, and the derivation still answers for a launch that never happened once the user edits a runtime preference. Prefer the recorded lane where one exists; keep the derivation for the panes main never records — pre-feature panes, LocalPtyProvider spawns and remote ids — and log when the two disagree. * refactor(codex): drop a redundant guard around the recorded-lane lookup --- src/main/codex-accounts/runtime-selection.ts | 52 +- src/main/codex/codex-pane-account-registry.ts | 20 + src/main/ipc/codex-accounts.ts | 9 + src/preload/api-types.ts | 2 + src/preload/index.ts | 2 + .../src/components/settings/AccountsPane.tsx | 22 +- .../src/components/status-bar/StatusBar.tsx | 7 +- .../src/lib/codex-pane-selection-lane.test.ts | 491 ++++++++++++++++++ .../src/lib/codex-pane-selection-lane.ts | 304 +++++++++++ .../src/lib/codex-session-restart.test.ts | 310 +++++++++++ src/renderer/src/lib/codex-session-restart.ts | 158 ++++-- .../src/lib/codex-stale-pane-sweep.test.ts | 46 ++ .../src/lib/codex-stale-pane-sweep.ts | 7 + src/renderer/src/web/web-preload-api.ts | 3 + src/shared/codex-selection-lane.ts | 36 ++ 15 files changed, 1399 insertions(+), 70 deletions(-) create mode 100644 src/renderer/src/lib/codex-pane-selection-lane.test.ts create mode 100644 src/renderer/src/lib/codex-pane-selection-lane.ts create mode 100644 src/shared/codex-selection-lane.ts diff --git a/src/main/codex-accounts/runtime-selection.ts b/src/main/codex-accounts/runtime-selection.ts index a8479dba19a..5ac9f91b013 100644 --- a/src/main/codex-accounts/runtime-selection.ts +++ b/src/main/codex-accounts/runtime-selection.ts @@ -3,28 +3,23 @@ import type { CodexManagedAccountRuntimeSelection, GlobalSettings } from '../../shared/types' +// Why: the renderer's switch-time lane guard has to key panes the same way a +// launch does, so the lane vocabulary lives in shared rather than in main. +import { + getWslSelectionKey, + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget +} from '../../shared/codex-selection-lane' -export type CodexAccountSelectionTarget = { - runtime?: 'host' | 'wsl' - wslDistro?: string | null -} - -export type NormalizedCodexAccountSelectionTarget = { - runtime: 'host' | 'wsl' - wslDistro: string | null -} - -export function normalizeCodexAccountSelectionTarget( - target?: CodexAccountSelectionTarget | null -): NormalizedCodexAccountSelectionTarget { - if (target?.runtime === 'wsl') { - return { - runtime: 'wsl', - wslDistro: normalizeWslDistro(target.wslDistro) - } - } - return { runtime: 'host', wslDistro: null } -} +export { + getCodexSelectionLaneKey, + getWslSelectionKey, + normalizeCodexAccountSelectionTarget +} from '../../shared/codex-selection-lane' +export type { + CodexAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget +} from '../../shared/codex-selection-lane' export function normalizeCodexRuntimeSelection( settings: Pick< @@ -135,18 +130,3 @@ export function getCodexSelectionTargetForAccount( } return { runtime: 'host' } } - -/** Stable identifier for the selection lane a launch resolves its account from. */ -export function getCodexSelectionLaneKey(target?: CodexAccountSelectionTarget | null): string { - const normalized = normalizeCodexAccountSelectionTarget(target) - return normalized.runtime === 'host' ? 'host' : `wsl:${getWslSelectionKey(normalized.wslDistro)}` -} - -export function getWslSelectionKey(wslDistro: string | null | undefined): string { - return normalizeWslDistro(wslDistro) ?? '__default__' -} - -function normalizeWslDistro(wslDistro: string | null | undefined): string | null { - const trimmed = wslDistro?.trim() - return trimmed ? trimmed : null -} diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index b5ad926736d..f1d83989453 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -139,6 +139,26 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } +/** + * Reports the lane each given PTY launched from, omitting panes with no record. + * + * Why the renderer needs this: an account switch has to know which panes the + * change could have stranded, and this key was written from the shell, cwd and + * distro the spawn actually resolved. Re-deriving it from current settings + * answers for a launch that never happened once the user edits those settings. + */ +export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record { + const registry = readRegistry() + const lanesByPtyId: Record = {} + for (const ptyId of ptyIds) { + const record = registry.panes[ptyId] + if (record) { + lanesByPtyId[ptyId] = record.selectionKey + } + } + return lanesByPtyId +} + export const _internals = { resetCache: (): void => { cachedRegistry = null diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index e5cad61d384..22cee5340d2 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { CodexAccountAddTarget, CodexAccountService } from '../codex-accounts/service' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { listRecordedCodexPaneLanes } from '../codex/codex-pane-account-registry' import { forgetStaleCodexPanes, listStaleCodexPanes } from '../codex/codex-stale-pane-accounts' import type { GlobalSettings } from '../../shared/types' @@ -18,6 +19,14 @@ export function registerCodexAccountHandlers( settings }) }) + ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => { + if (!Array.isArray(args?.ptyIds)) { + return {} + } + return listRecordedCodexPaneLanes( + args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string') + ) + }) ipcMain.handle('codexAccounts:forgetStalePanes', (_event, args: { ptyIds?: unknown }) => { if (!Array.isArray(args?.ptyIds)) { return diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a9218f0c457..d60947df694 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2342,6 +2342,8 @@ export type PreloadApi = { }) => Promise< { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] > + /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ + listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> /** Drops launch records so a dismissed prompt stays dismissed across restarts. */ forgetStalePanes: (args: { ptyIds: string[] }) => Promise } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c28865b1e1..bb0a17220ed 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1999,6 +1999,8 @@ const api = { }): Promise< { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), + listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => + ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), forgetStalePanes: (args: { ptyIds: string[] }): Promise => ipcRenderer.invoke('codexAccounts:forgetStalePanes', args) }, diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 6f96cd2e1f4..4188db5c831 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -745,9 +745,29 @@ export function AccountsPane({ action === `reauth:${nextActiveAccountId}`) || (action.startsWith('remove:') && previousActiveAccountId !== nextActiveAccountId) if (shouldPromptRestart) { + // Why: `add` creates the managed home against the machine's own distro, + // so the slot it wrote is the created account's — not this row's, which + // may still say "WSL default". Found by diffing the roster rather than + // by the row's active id, which resolves to null once two distro slots + // are filled and would send the notice to the wrong lane. + const newAccounts = + action === 'adding' + ? next.accounts.filter( + (account) => !codexAccounts.accounts.some((prior) => prior.id === account.id) + ) + : [] + // Why exactly one: an unloaded prior roster makes every account look new, + // and picking one of those would aim the notice at an unrelated lane. + // Falling back to the row is the pre-existing behaviour, not a new risk. + const addedAccount = newAccounts.length === 1 ? newAccounts[0] : undefined void markLiveCodexSessionsForRestart({ previousAccountLabel: getCodexAccountLabel(codexAccounts, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) + nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId), + // Why: the mutation wrote this row's slot only, so panes on any other + // lane still launch under the account they already had. + target: addedAccount ? getProviderAccountRuntime(addedAccount) : actionRuntime, + // Why: clearing a distro-less WSL row nulls every distro slot at once. + clearsEveryWslDistro: action === 'select:system' }) } } catch (error) { diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 89cb89eb534..2fb006b34d9 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1467,7 +1467,12 @@ export function CodexSwitcherMenu({ if (previousActiveAccountId !== nextActiveAccountId) { await markLiveCodexSessionsForRestart({ previousAccountLabel: getCodexAccountLabel(accountState, previousActiveAccountId), - nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId) + nextAccountLabel: getCodexAccountLabel(next, nextActiveAccountId), + // Why: the mutation wrote this row's slot only, so panes on any other + // lane still launch under the account they already had. + target, + // Why: clearing a distro-less WSL row nulls every distro slot at once. + clearsEveryWslDistro: accountId === null }) // Why: collapse to the summary row (not close) so the follow-up "restart open tabs" prompt appears in the same flow. if (mountedRef.current) { diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts new file mode 100644 index 00000000000..a4b1dcefc56 --- /dev/null +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -0,0 +1,491 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { getCodexSelectionLaneKey } from '../../../shared/codex-selection-lane' +import { + getCodexAccountSwitchLaneMatcher, + isForeignMachineCodexPtyId, + isLocalCodexSelectionLaneKey, + resolveCodexPaneSelectionLane, + resolveCodexPaneSelectionLaneKey +} from './codex-pane-selection-lane' + +type LaneState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +function laneState(args?: { + activeRuntimeEnvironmentId?: string | null + worktreePath?: string + folderPath?: string + terminalWindowsShell?: string + terminalWindowsWslDistro?: string | null + projectWslDistro?: string + localWindowsRuntimeDefault?: { kind: 'wsl'; distro: string | null } +}): LaneState { + return { + folderWorkspaces: args?.folderPath ? [{ id: 'fw1', folderPath: args.folderPath }] : [], + settings: { + activeRuntimeEnvironmentId: args?.activeRuntimeEnvironmentId ?? null, + ...(args?.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), + ...(args?.localWindowsRuntimeDefault + ? { localWindowsRuntimeDefault: args.localWindowsRuntimeDefault } + : {}), + terminalWindowsWslDistro: args?.terminalWindowsWslDistro ?? null + }, + repos: [{ id: 'repo1', path: 'C:\\code\\app' }], + projects: args?.projectWslDistro + ? [ + { + id: 'proj1', + sourceRepoIds: ['repo1'], + localWindowsRuntimePreference: { kind: 'wsl', distro: args.projectWslDistro } + } + ] + : [], + worktreesByRepo: { + repo1: [{ id: 'wt1', repoId: 'repo1', path: args?.worktreePath ?? '/Users/dev/code/orca' }] + } + } as unknown as LaneState +} + +/** The Windows-only shell resolution is gated on the renderer platform. */ +function withWindowsRenderer(run: () => void): void { + const originalNavigator = globalThis.navigator + Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, + configurable: true + }) + try { + run() + } finally { + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + configurable: true + }) + } +} + +const HOST_TAB = { worktreeId: 'wt1', shellOverride: undefined } + +describe('resolveCodexPaneSelectionLaneKey', () => { + it('keys an ordinary local pane to the host lane', () => { + expect( + resolveCodexPaneSelectionLaneKey({ state: laneState(), tab: HOST_TAB, ptyId: 'pty-1' }) + ).toBe('host') + }) + + it('keys a pane in a WSL UNC worktree to that distro lane', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + + it('keys a wsl.exe pane outside a UNC worktree to the default WSL lane', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState(), + tab: { worktreeId: 'wt1', shellOverride: 'wsl.exe' }, + ptyId: 'pty-1' + }) + ).toBe('wsl:__default__') + }) + + // Why this matters: pty.ts keys such a pane `wsl:` from the resolved + // runtime, so keying it `wsl:__default__` would make its own distro's switch + // miss it — the pane keeps the old account with no notice. + it('keys a wsl.exe pane on a Windows-path worktree to the configured distro', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + worktreePath: 'C:\\code\\app' + }), + tab: { worktreeId: 'wt1', shellOverride: 'wsl.exe' }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + }) + + it('still keys an ordinary host pane to host when a WSL distro is configured', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ terminalWindowsWslDistro: 'Ubuntu', worktreePath: 'C:\\code\\app' }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('host') + }) + }) + + // Why: main resolves the shell through resolveLocalWindowsTerminalRuntimeOptions, + // so an unset override still lands on WSL when that is the Windows default. + // Reading only the tab would key this pane `host` and mute it on a host switch. + it('keys an override-less pane by the default Windows shell', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + worktreePath: 'C:\\code\\app' + }), + tab: { worktreeId: 'wt1', shellOverride: undefined }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + }) + + // Why: the startup cwd is deliberately unconstrained (#7685), and main keys + // the lane off it, so a pane split across filesystems must follow the cwd. + it('follows the pane startup cwd out of the workspace filesystem', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ worktreePath: 'C:\\code\\app' }), + tab: { + worktreeId: 'wt1', + shellOverride: undefined, + startupCwd: '\\\\wsl.localhost\\Ubuntu\\home\\dev' + }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Ubuntu') + }) + + it("keys a pane by its own project's WSL distro", () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ projectWslDistro: 'Debian', worktreePath: 'C:\\code\\app' }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('wsl:Debian') + }) + }) + + // Why: a floating terminal's cwd never reaches the tab, so it is keyed by its + // shell. Pinning that it does not throw or resolve against some other + // workspace's root, which is what the lane would otherwise inherit. + it('keys a floating terminal by its shell, not another workspace root', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\dev' }), + tab: { worktreeId: 'global-floating-terminal', shellOverride: undefined }, + ptyId: 'pty-1' + }) + ).toBe('host') + }) + + // Why: resolveLocalWindowsTerminalRuntimeOptions THROWS on repair-required, + // and this runs outside scanCodexPanes' per-pane failure guard — so without + // the early return the Promise.all rejects and EVERY pane in the batch loses + // its notice, not just this one. + it('answers a repair-required runtime instead of throwing the batch away', () => { + withWindowsRenderer(() => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ + localWindowsRuntimeDefault: { kind: 'wsl', distro: null }, + worktreePath: 'C:\\code\\app' + }), + tab: HOST_TAB, + ptyId: 'pty-1' + }) + ).toBe('wsl:__default__') + }) + }) + + it('reads the distro from a folder workspace path too', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ folderPath: '\\\\wsl$\\Debian\\srv\\app' }), + tab: { worktreeId: 'folder:fw1', shellOverride: undefined }, + ptyId: 'pty-1' + }) + ).toBe('wsl:Debian') + }) + + it('keys an owned remote runtime pane to its own environment, not the active one', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ activeRuntimeEnvironmentId: 'env-active' }), + tab: HOST_TAB, + ptyId: 'remote:env-owner@@term-1' + }) + ).toBe('env:env-owner') + }) + + it('routes an owner-less remote pane to the active environment, as inspection does', () => { + expect( + resolveCodexPaneSelectionLaneKey({ + state: laneState({ activeRuntimeEnvironmentId: 'env-1' }), + tab: HOST_TAB, + ptyId: 'remote:term-1' + }) + ).toBe('env:env-1') + }) + + it('keeps an owner-less remote pane off the host lane when no environment is active', () => { + const laneKey = resolveCodexPaneSelectionLaneKey({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'remote:term-1' + }) + // Why assert disjointness rather than the literal key: colliding with `host` + // is the whole failure mode — a local switch would mute a working remote pane. + expect(laneKey).not.toBe(getCodexSelectionLaneKey({ runtime: 'host' })) + expect(isLocalCodexSelectionLaneKey(laneKey)).toBe(false) + }) + + it('keys an SSH-connection pane to a lane no account selection can name', () => { + const laneKey = resolveCodexPaneSelectionLaneKey({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'ssh:my-box@@pty-7' + }) + expect(laneKey).toBe('ssh-connection') + // Why: managed Codex accounts are only ever 'host' or 'wsl:', so no + // switch can produce this key — the pane is unreachable by any selection. + expect(laneKey).not.toBe(getCodexSelectionLaneKey({ runtime: 'host' })) + expect(isLocalCodexSelectionLaneKey(laneKey)).toBe(false) + }) +}) + +describe('resolveCodexPaneSelectionLane', () => { + it('prefers the lane main recorded at spawn over the current derivation', () => { + // Why this is the whole point: the derivation reads CURRENT state, so the + // user editing a runtime preference after the pane opened must not re-key it. + const lane = resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'wsl:Ubuntu' + }) + expect(lane).toEqual({ + laneKey: 'wsl:Ubuntu', + source: 'recorded', + derivedLaneKey: 'host' + }) + }) + + it('reports the disagreement so a re-derivation bug stays diagnosable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'wsl:Ubuntu' + }) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('[codex-lane]'), + expect.objectContaining({ ptyId: 'pty-1', recorded: 'wsl:Ubuntu', derived: 'host' }) + ) + } finally { + warn.mockRestore() + } + }) + + it('stays quiet when the record and the derivation agree', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const lane = resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'host' + }) + expect(lane.source).toBe('recorded') + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + // THE regression that matters: over-filtering silently kills the feature. + it('derives a local host pane that main never recorded', () => { + for (const recordedLaneKey of [undefined, null, '', ' ']) { + expect( + resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey + }) + ).toEqual({ laneKey: 'host', source: 'derived', derivedLaneKey: 'host' }) + } + }) + + it('falls back to the derivation when the record names no selectable lane', () => { + // Why: the registry accepts any string it finds on disk, and a lane key that + // matches no switch would silently drop the pane's notice instead of failing. + expect( + resolveCodexPaneSelectionLane({ + state: laneState(), + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'env:env-1' + }) + ).toEqual({ laneKey: 'host', source: 'derived', derivedLaneKey: 'host' }) + }) + + it.each([ + ['remote:env-1@@term-1', 'env:env-1'], + ['ssh:my-box@@pty-7', 'ssh-connection'] + ])('keeps a record from re-keying the foreign pane %s', (ptyId, expectedLaneKey) => { + // Why: a foreign pane's lane is settled by its id, so a record here can only + // be a recycled id — and honouring it would mute a working remote terminal. + expect( + resolveCodexPaneSelectionLane({ + state: laneState({ activeRuntimeEnvironmentId: 'env-1' }), + tab: HOST_TAB, + ptyId, + recordedLaneKey: 'host' + }) + ).toEqual({ laneKey: expectedLaneKey, source: 'derived', derivedLaneKey: expectedLaneKey }) + }) + + it('still answers with the record when the derivation throws', () => { + const exploding = new Proxy(laneState(), { + get(target, property) { + if (property === 'worktreesByRepo') { + throw new Error('state read blew up') + } + return Reflect.get(target, property) + } + }) as LaneState + // Why: this call sits outside the scan's per-pane failure guard, so a throw + // would lose the notice for every pane in the batch, not just this one. + expect( + resolveCodexPaneSelectionLane({ + state: exploding, + tab: HOST_TAB, + ptyId: 'pty-1', + recordedLaneKey: 'host' + }) + ).toEqual({ laneKey: 'host', source: 'recorded', derivedLaneKey: null }) + }) +}) + +describe('getCodexAccountSwitchLaneMatcher', () => { + it('scopes a local switch to the runtime slot it wrote', () => { + const hostSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'host' } + }) + expect(hostSwitch('host')).toBe(true) + expect(hostSwitch('wsl:Ubuntu')).toBe(false) + + const ubuntuSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + expect(ubuntuSwitch('wsl:Ubuntu')).toBe(true) + expect(ubuntuSwitch('wsl:Debian')).toBe(false) + expect(ubuntuSwitch('host')).toBe(false) + }) + + // Why a family: clearing a distro-less WSL selection nulls EVERY wsl slot, so + // matching only `wsl:__default__` would leave those panes stranded, unnoticed. + it('claims every WSL distro when the change cleared them all', () => { + const wslDefaultSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: null }, + clearsEveryWslDistro: true + }) + expect(wslDefaultSwitch('wsl:__default__')).toBe(true) + expect(wslDefaultSwitch('wsl:Ubuntu')).toBe(true) + expect(wslDefaultSwitch('wsl:Debian')).toBe(true) + // Still cannot reach another machine, which is the point of the guard. + expect(wslDefaultSwitch('host')).toBe(false) + expect(wslDefaultSwitch('env:env-1')).toBe(false) + expect(wslDefaultSwitch('ssh-connection')).toBe(false) + expect(wslDefaultSwitch('remote-runtime')).toBe(false) + }) + + // Why the negative half matters: pointing a distro-less WSL row at a real + // account writes only the `__default__` slot, so claiming the family there + // would card — and mute — every sibling distro's healthy Codex pane. + it('keeps a distro-less WSL selection off sibling distro panes', () => { + const wslDefaultSelect = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: null } + }) + expect(wslDefaultSelect('wsl:__default__')).toBe(true) + expect(wslDefaultSelect('wsl:Ubuntu')).toBe(false) + expect(wslDefaultSelect('host')).toBe(false) + }) + + // Why: StatusBar passes clearsEveryWslDistro for the "System default" row of + // EVERY WSL group, including a concrete-distro one, where the clear lands in + // that slot alone. Without the null-distro condition it would mute them all. + it('keeps a cleared concrete-distro row off the other distros', () => { + const ubuntuClear = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, + clearsEveryWslDistro: true + }) + expect(ubuntuClear('wsl:Ubuntu')).toBe(true) + expect(ubuntuClear('wsl:Debian')).toBe(false) + expect(ubuntuClear('wsl:__default__')).toBe(false) + }) + + it('scopes a switch made against a runtime environment to that machine', () => { + const environmentSwitch = getCodexAccountSwitchLaneMatcher({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { runtime: 'host' } + }) + expect(environmentSwitch('env:env-1')).toBe(true) + expect(environmentSwitch('host')).toBe(false) + expect(environmentSwitch('env:env-2')).toBe(false) + }) + + it('never lets a local host switch claim a remote or SSH pane', () => { + const hostSwitch = getCodexAccountSwitchLaneMatcher({ + settings: null, + target: { runtime: 'host' } + }) + const state = laneState() + for (const ptyId of ['remote:env-owner@@term-1', 'remote:term-1', 'ssh:my-box@@pty-7']) { + expect(hostSwitch(resolveCodexPaneSelectionLaneKey({ state, tab: HOST_TAB, ptyId }))).toBe( + false + ) + } + }) +}) + +describe('isForeignMachineCodexPtyId', () => { + it('separates panes whose shell runs on another machine from local ones', () => { + expect(isForeignMachineCodexPtyId('remote:env-1@@term-1')).toBe(true) + expect(isForeignMachineCodexPtyId('remote:term-1')).toBe(true) + expect(isForeignMachineCodexPtyId('ssh:my-box@@pty-7')).toBe(true) + expect(isForeignMachineCodexPtyId('pty-1')).toBe(false) + }) + + it('agrees with the lane keys, so the sweep and the scan skip the same panes', () => { + const state = laneState({ activeRuntimeEnvironmentId: 'env-1' }) + for (const ptyId of ['remote:env-1@@term-1', 'remote:term-1', 'ssh:my-box@@pty-7', 'pty-1']) { + expect( + isLocalCodexSelectionLaneKey( + resolveCodexPaneSelectionLaneKey({ state, tab: HOST_TAB, ptyId }) + ) + ).toBe(!isForeignMachineCodexPtyId(ptyId)) + } + }) +}) diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts new file mode 100644 index 00000000000..e6c8a6877da --- /dev/null +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -0,0 +1,304 @@ +import type { AppState } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { + getCodexSelectionLaneKey, + normalizeCodexAccountSelectionTarget, + type CodexAccountSelectionTarget +} from '../../../shared/codex-selection-lane' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions, + type LocalWindowsTerminalRuntimeOptions +} from '../../../shared/local-windows-terminal-runtime' +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { resolveTerminalStartupCwd } from '../../../shared/terminal-startup-cwd' +import type { GlobalSettings, TerminalTab } from '../../../shared/types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { parseWslUncPath } from '../../../shared/wsl-paths' +import { getLocalProjectExecutionRuntimeContext } from './local-preflight-context' +import { getRendererAppPlatform } from './renderer-app-platform' +import { + getCachedWindowsTerminalCapabilities, + hasCachedWindowsTerminalCapabilities +} from './windows-terminal-capabilities' + +type RuntimeEnvironmentSettings = Pick + +/** Everything the pane lane needs: the workspace path plus the project runtime inputs. */ +type CodexPaneLaneState = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projects' + | 'repos' + | 'settings' + | 'worktreesByRepo' +> + +/** + * Lane keys for panes whose Codex credentials come from another machine. + * + * Why they need keys at all: a managed Codex account is scoped to one machine + * AND one runtime (`host` or `wsl:`). A relay environment keeps its own + * account roster, and an SSH connection has no Orca-managed selection whatsoever + * — the remote Codex reads that machine's own credentials. Neither can be + * stranded by a local selection change, so they must not share the local keys. + */ +const RUNTIME_ENVIRONMENT_LANE_PREFIX = 'env:' +const SSH_CONNECTION_LANE_KEY = 'ssh-connection' +const UNATTRIBUTED_REMOTE_LANE_KEY = 'remote-runtime' +const HOST_LANE_KEY = 'host' +const WSL_LANE_PREFIX = 'wsl:' + +/** True for the lanes an on-disk pane-account record can name. */ +export function isLocalCodexSelectionLaneKey(laneKey: string): boolean { + return laneKey === HOST_LANE_KEY || laneKey.startsWith(WSL_LANE_PREFIX) +} + +/** + * True when the pane's shell runs on a machine other than this one. + * + * Why it takes only the id: a `remote:`/`ssh:` prefix is assigned at spawn and + * is decisive on its own, so callers with no store access (the bind-driven + * sweep) can skip these panes before spending a 15s RPC on them. + */ +export function isForeignMachineCodexPtyId(ptyId: string): boolean { + return parseRemoteRuntimePtyId(ptyId) !== null || parseAppSshPtyId(ptyId) !== null +} + +/** Matches the panes a Codex account mutation could have re-pointed. */ +export function getCodexAccountSwitchLaneMatcher(args: { + settings: RuntimeEnvironmentSettings | null | undefined + target?: CodexAccountSelectionTarget | null + /** + * True only when the mutation cleared every WSL distro slot at once, which + * setSelectedCodexAccountIdForTarget does for a null account on a distro-less + * WSL target. Any other write lands in a single slot, so defaulting this to + * false keeps the matcher from muting a sibling distro's healthy panes. + */ + clearsEveryWslDistro?: boolean +}): (laneKey: string) => boolean { + const runtimeTarget = getActiveRuntimeTarget(args.settings) + // Why: with an environment active the mutation is RPC'd to that machine's + // roster and local GlobalSettings are never touched, so the local host/WSL + // panes are exactly the ones the switch cannot have affected. + if (runtimeTarget.kind === 'environment') { + const environmentLaneKey = `${RUNTIME_ENVIRONMENT_LANE_PREFIX}${runtimeTarget.environmentId}` + return (laneKey) => laneKey === environmentLaneKey + } + const normalized = normalizeCodexAccountSelectionTarget(args.target) + // Why a family rather than the `wsl:__default__` key: clearing a distro-less + // WSL selection nulls every distro slot, so every WSL pane really is stranded. + // Keying that to `__default__` alone would leave them all without a notice. + if (args.clearsEveryWslDistro && normalized.runtime === 'wsl' && normalized.wslDistro === null) { + return (laneKey) => laneKey.startsWith(WSL_LANE_PREFIX) + } + const switchLaneKey = getCodexSelectionLaneKey(normalized) + return (laneKey) => laneKey === switchLaneKey +} + +export type CodexPaneSelectionLane = { + /** The lane the caller must filter on. */ + laneKey: string + /** Which answer won: main's spawn-time record, or the renderer's re-derivation. */ + source: 'recorded' | 'derived' + /** What the derivation said, or null when it threw. Diagnostics only. */ + derivedLaneKey: string | null +} + +/** + * The lane a pane launched from, preferring the one main recorded at spawn. + * + * Why recorded wins: main writes `selectionKey` from the shell, cwd and distro + * the spawn actually resolved, so it cannot drift. The derivation below reads + * CURRENT state, so flipping the global WSL distro or a project's runtime + * preference after a pane opened makes it answer for a launch that never + * happened — a missed notice one way, a muted working terminal the other. + * + * The derivation stays because it is the only answer for the panes main never + * records: every pre-feature pane, and every LocalPtyProvider or remote spawn. + */ +export function resolveCodexPaneSelectionLane(args: { + state: CodexPaneLaneState + tab: Pick + ptyId: string + /** The pane's `selectionKey` from the on-disk registry, when it has one. */ + recordedLaneKey?: string | null +}): CodexPaneSelectionLane { + const recorded = args.recordedLaneKey?.trim() + // Why the local-key check: the registry accepts any string it finds on disk, + // and a lane key that matches no switch silently drops that pane's notice. + // Why foreign ids still derive: their lane is settled by the id itself and no + // record can exist for one, so a hit here would mean a recycled id. + const trustsRecord = + Boolean(recorded) && + isLocalCodexSelectionLaneKey(recorded as string) && + !isForeignMachineCodexPtyId(args.ptyId) + if (!trustsRecord) { + const laneKey = resolveCodexPaneSelectionLaneKey(args) + return { laneKey, source: 'derived', derivedLaneKey: laneKey } + } + const derivedLaneKey = deriveLaneKeyForDiagnostics(args) + if (derivedLaneKey !== null && derivedLaneKey !== recorded) { + // Why loud: every divergence found in review was this exact disagreement, + // and the recorded key now hides it instead of producing a visible bug. + console.warn('[codex-lane] recorded launch lane disagrees with the derived one:', { + ptyId: args.ptyId, + recorded, + derived: derivedLaneKey + }) + } + return { laneKey: recorded as string, source: 'recorded', derivedLaneKey } +} + +/** Never let the diagnostic derivation break a pane whose lane is already known. */ +function deriveLaneKeyForDiagnostics(args: { + state: CodexPaneLaneState + tab: Pick + ptyId: string +}): string | null { + try { + return resolveCodexPaneSelectionLaneKey(args) + } catch { + return null + } +} + +/** The lane a live pane resolves its Codex account from, re-derived from state. */ +export function resolveCodexPaneSelectionLaneKey(args: { + state: CodexPaneLaneState + tab: Pick + ptyId: string +}): string { + const remoteParts = parseRemoteRuntimePtyId(args.ptyId) + if (remoteParts !== null) { + const runtimeTarget = getActiveRuntimeTarget(args.state.settings) + // Why: mirror inspectRuntimeTerminalProcess — an owner-less remote id is + // routed to whichever environment is active, so that is its lane too. + const environmentId = + remoteParts.environmentId?.trim() || + (runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null) + return environmentId + ? `${RUNTIME_ENVIRONMENT_LANE_PREFIX}${environmentId}` + : UNATTRIBUTED_REMOTE_LANE_KEY + } + if (parseAppSshPtyId(args.ptyId) !== null) { + return SSH_CONNECTION_LANE_KEY + } + return getCodexSelectionLaneKey(resolveLocalPaneSelectionTarget(args)) +} + +/** + * Mirrors the main-process getCodexSelectionTargetForPty, from renderer state. + * + * Why the pane cwd and not the workspace root: a terminal's startup cwd is + * deliberately NOT constrained to the worktree (see resolveTerminalStartupCwd, + * #7685), so a pane split after `cd \\wsl.localhost\...` runs on a different + * filesystem than its workspace. Main keys the lane off that cwd, so reading the + * root instead would call a live WSL pane `host` and mute it on a host switch. + */ +function resolveLocalPaneSelectionTarget(args: { + state: CodexPaneLaneState + tab: Pick +}): CodexAccountSelectionTarget { + const paneCwd = resolvePaneCwd(args) + const wslPath = paneCwd ? parseWslUncPath(paneCwd) : null + if (wslPath) { + return { runtime: 'wsl', wslDistro: wslPath.distro } + } + const terminalRuntime = resolveLocalPaneTerminalRuntime(args) + if (isWslShellName(terminalRuntime.shellOverride)) { + return { runtime: 'wsl', wslDistro: terminalRuntime.terminalWindowsWslDistro } + } + return { runtime: 'host' } +} + +/** The absolute directory the pane's shell was spawned in, as main resolved it. */ +function resolvePaneCwd(args: { + state: CodexPaneLaneState + tab: Pick +}): string | null { + // Why floating terminals get no cwd: theirs never reaches the tab. It is + // resolved over IPC from settings.floatingTerminalCwd and handed to the + // transport as a prop, so the store cannot see the path main keyed off. Such a + // pane falls through to its shell below, which is right unless the configured + // floating cwd is a WSL UNC path under a host shell — a known gap, not a guess + // worth making, since guessing wrong here mutes a working terminal. + if (args.tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + return null + } + const workspacePath = getWorkspacePath(args.state, args.tab.worktreeId) + if (!workspacePath) { + return null + } + // Why this exact call: it is the same one main spawns through, so a relative + // or inherited startup folder resolves to the identical absolute path. + return resolveTerminalStartupCwd(workspacePath, args.tab.startupCwd) ?? workspacePath +} + +/** + * The shell and distro the launch resolved, not merely the ones the tab asked for. + * + * Why getLocalProjectExecutionRuntimeContext specifically: for a local pane the + * RENDERER computes the project runtime and ships it with the spawn + * (pty-connection.ts), so this is not an approximation of main — it is the same + * call on the same state. Re-deriving it by hand drops the global WSL default, + * which turns `inherit-global` into WSL and would key a live WSL pane `host`. + */ +function resolveLocalPaneTerminalRuntime(args: { + state: CodexPaneLaneState + tab: Pick +}): LocalWindowsTerminalRuntimeOptions { + // Why the platform gate: pty.ts only consults the Windows terminal runtime on + // win32, so elsewhere the tab's own override is the whole answer. + if (getRendererAppPlatform() !== 'win32') { + return { shellOverride: args.tab.shellOverride, terminalWindowsWslDistro: null } + } + const capabilities = hasCachedWindowsTerminalCapabilities() + ? getCachedWindowsTerminalCapabilities() + : null + const projectRuntime = getLocalProjectExecutionRuntimeContext( + args.state, + args.tab.worktreeId, + undefined, + { + wslAvailable: capabilities?.wslAvailable, + availableWslDistros: capabilities?.wslDistros ?? null + } + ) + if (projectRuntime?.status === 'repair-required') { + // Why not delegate: resolveLocalWindowsTerminalRuntimeOptions throws here, + // and this call sits outside the scan's per-pane failure guard, so a throw + // would lose the notice for every pane in the batch, not just this one. + return { + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: projectRuntime.repair.preferredRuntime.distro + } + } + return resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.tab.shellOverride, + settings: args.state.settings ?? undefined, + projectRuntime + }) +} + +function getWorkspacePath( + state: Pick, + worktreeId: string +): string | null { + const parsed = parseWorkspaceKey(worktreeId) + if (parsed?.type === 'folder') { + return ( + (state.folderWorkspaces ?? []).find((workspace) => workspace.id === parsed.folderWorkspaceId) + ?.folderPath ?? null + ) + } + return ( + Object.values(state.worktreesByRepo ?? {}) + .flat() + .find((entry) => entry.id === worktreeId)?.path ?? null + ) +} diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index 7e03475bdcb..3c9c2c816b0 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -416,6 +416,316 @@ describe('markLiveCodexSessionsForRestart', () => { }) }) +/** + * A restart notice blocks every keystroke in the pane it names, so raising one + * on a pane the switch could not have touched takes a working terminal deaf. + * A managed Codex account is scoped to one machine AND one runtime: a remote + * spawn carries a connectionId, so no CODEX_HOME is ever injected into it, and + * WSL selections live in their own per-distro slot. + */ +describe('markLiveCodexSessionsForRestart lane scoping', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const runtimeEnvironmentCall = vi.fn() + const runtimeEnvironmentTransportCall = vi.fn() + + function seedPanes( + panes: { ptyId: string; worktreeId?: string; shellOverride?: string }[], + worktreePaths: Record = {} + ): void { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: null } as never, + worktreesByRepo: { + repo1: [ + { id: 'wt1', path: worktreePaths.wt1 ?? '/Users/dev/code/orca' }, + ...(worktreePaths.wt2 ? [{ id: 'wt2', path: worktreePaths.wt2 }] : []) + ] + } as never, + tabsByWorktree: { + wt1: panes.map((pane, index) => ({ + id: `tab-${index}`, + ptyId: pane.ptyId, + worktreeId: pane.worktreeId ?? 'wt1', + title: `orca-${index}`, + customTitle: null, + color: null, + sortOrder: index, + createdAt: 1, + launchAgent: 'codex' as const, + ...(pane.shellOverride ? { shellOverride: pane.shellOverride } : {}) + })) + }, + ptyIdsByTabId: Object.fromEntries(panes.map((pane, index) => [`tab-${index}`, [pane.ptyId]])), + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {} + }) + } + + beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + // Why: every pane in this block reads as a live Codex session, so any pane + // left uncarded was excluded by its lane and nothing else. + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { process: { foregroundProcess: 'codex', hasChildProcesses: true } }, + _meta: { runtimeId: 'remote-runtime' } + }) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + inspectProcess: vi + .fn() + .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: true }) + }, + codexAccounts: { + ...originalWindow?.api?.codexAccounts, + list: vi.fn().mockResolvedValue({ accounts: [], activeAccountId: null }), + listStalePanes: vi.fn().mockResolvedValue([]), + listRecordedPaneLanes: vi.fn().mockResolvedValue({}) + }, + runtimeEnvironments: { + ...originalWindow?.api?.runtimeEnvironments, + call: runtimeEnvironmentTransportCall + } + } + } as unknown as typeof window + }) + + afterEach(() => { + useAppStore.setState({ settings: null as never, worktreesByRepo: {} as never }) + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('leaves a live remote Codex pane alone on a host switch, and never inspects it', async () => { + seedPanes([{ ptyId: 'remote:env-1@@term-1' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('leaves a live SSH-connection Codex pane alone on a host switch', async () => { + seedPanes([{ ptyId: 'ssh:my-box@@pty-7' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + it('still marks the local host pane while sparing the remote one beside it', async () => { + seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-1') + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('still marks a local host pane when the switch names no target at all', async () => { + seedPanes([{ ptyId: 'pty-1' }]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-1': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + }) + + // Why these two: a Windows validation saw a WSL pane escape a host switch, but + // only because its foreground read as `wsl.exe` and failed the Codex test. Pin + // the lane instead — a WSL pane whose foreground IS codex must escape too. + it('leaves a WSL Codex pane alone on a host switch even when its foreground is codex', async () => { + seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + it('marks that same WSL pane when its own distro is the lane that changed', async () => { + seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'wsl', wslDistro: 'Ubuntu' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'pty-wsl': { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B } + }) + }) + + it('keeps one distro switch off another distro pane', async () => { + seedPanes([{ ptyId: 'pty-wsl' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'wsl', wslDistro: 'Debian' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + }) + + it('leaves the local host pane alone when the switch was made on a runtime environment', async () => { + seedPanes([{ ptyId: 'pty-1' }, { ptyId: 'remote:env-1@@term-1' }]) + useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + // Why: that mutation was RPC'd to env-1's own roster, so env-1's panes are + // the stale ones and the local shell is untouched — the mirror of the bug. + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({ + 'remote:env-1@@term-1': { + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + } + }) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + /** + * Main writes the lane from the shell, cwd and distro the spawn resolved, so + * it is exact where re-deriving from current state can only approximate. Four + * review rounds each found another divergence in that derivation; these pin + * the record beating it in both directions. + */ + describe('recorded launch lanes', () => { + it('spares a pane the record puts in another lane, and never inspects it', async () => { + // Derivation says `host`; the pane really launched under WSL. Carding it + // would take a working terminal deaf — this is the bug class in one test. + seedPanes([{ ptyId: 'pty-1' }]) + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue({ + 'pty-1': 'wsl:Ubuntu' + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + }) + + it('cards a pane the record puts in the switched lane against the derivation', async () => { + // The mirror: the user changed a runtime preference after this WSL-looking + // pane spawned on the host, and re-derivation would now miss its notice. + seedPanes([{ ptyId: 'pty-1' }], { wt1: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }) + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue({ + 'pty-1': 'host' + }) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) + + // THE regression check: over-filtering silently kills the feature and reads + // as a pass. A genuine local host pane must be carded on every fallback path. + it.each([ + ['no record exists for the pane', () => ({ 'pty-other': 'wsl:Ubuntu' })], + ['the lookup rejects', null], + ['the preload predates the lookup', undefined] + ])('still cards a local host pane when %s', async (_label, recorded) => { + seedPanes([{ ptyId: 'pty-1' }]) + if (recorded === null) { + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockRejectedValue( + new Error('no handler') + ) + } else if (recorded === undefined) { + ;( + window.api.codexAccounts as unknown as { listRecordedPaneLanes?: unknown } + ).listRecordedPaneLanes = undefined + } else { + vi.mocked(window.api.codexAccounts.listRecordedPaneLanes).mockResolvedValue(recorded()) + } + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) + + it('asks only about panes main could have recorded', async () => { + seedPanes([ + { ptyId: 'pty-1' }, + { ptyId: 'remote:env-1@@term-1' }, + { ptyId: 'ssh:my-box@@pty-7' } + ]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + target: { runtime: 'host' } + }) + + // Why: main only records daemon host spawns, so a foreign id is a certain + // miss — and one batched call, not one per pane. + expect(window.api.codexAccounts.listRecordedPaneLanes).toHaveBeenCalledTimes(1) + expect(window.api.codexAccounts.listRecordedPaneLanes).toHaveBeenCalledWith({ + ptyIds: ['pty-1'] + }) + }) + }) +}) + describe('markRestoredStaleCodexSessionsForRestart', () => { const originalWindow = (globalThis as { window?: typeof window }).window diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index ddde138f48b..cf5a6f5ac2c 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -3,6 +3,13 @@ import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' import { translate } from '@/i18n/i18n' import { isCodexRestartEligiblePane } from './codex-pane-restart-eligibility' +import { + getCodexAccountSwitchLaneMatcher, + isForeignMachineCodexPtyId, + isLocalCodexSelectionLaneKey, + resolveCodexPaneSelectionLane +} from './codex-pane-selection-lane' +import type { CodexAccountSelectionTarget } from '../../../shared/codex-selection-lane' // Why: prompt integrations such as Starship can outlast the daemon's 300ms // Codex fast-path timeout; account restarts must wait until the shell accepts input. @@ -21,52 +28,131 @@ export type CodexPaneScanResult = { launchedCodex: boolean /** A restart notice was raised for this pane by this scan. */ notified: boolean + /** The lane this pane was filtered on. */ + laneKey: string + /** Whether that lane came from main's spawn record or the renderer's derivation. */ + laneSource: 'recorded' | 'derived' } +/** + * Asks main which lane each pane actually launched from. + * + * Why failure is silent: the answer only upgrades the derivation's accuracy, so + * an older preload, a web client, or a missing registry must degrade to the + * derivation rather than lose every pane's restart notice. + */ +async function readRecordedCodexPaneLanes( + ptyIds: readonly string[] +): Promise> { + // Why filtered: main only records daemon host spawns, so asking about a + // remote or SSH pane is a guaranteed miss. + const localPtyIds = ptyIds.filter((ptyId) => !isForeignMachineCodexPtyId(ptyId)) + if (localPtyIds.length === 0) { + return {} + } + const listRecordedPaneLanes = window.api.codexAccounts.listRecordedPaneLanes + // Why the shape check: a preload older than this handler has no such method, + // and reaching that case must read as "no records", not as a scan failure. + if (typeof listRecordedPaneLanes !== 'function') { + return {} + } + return await listRecordedPaneLanes({ ptyIds: localPtyIds }).catch(() => ({})) +} + +/** + * Reports which panes are running Codex, skipping any outside the caller's lane. + * + * Why the lane filter runs BEFORE inspection rather than after: an out-of-lane + * answer cannot change the outcome, and the inspection is not free — a pane on a + * relay environment costs a 15s-timeout RPC per look. Skipping first is also + * what keeps a restart notice (which drops every keystroke in the pane) off a + * remote Codex session that no local account change can possibly strand. + */ async function scanCodexPanes( state: AppState, - ptyIdFilter: ReadonlySet | null + args: { + ptyIdFilter: ReadonlySet | null + isLaneInScope: (laneKey: string) => boolean + } ): Promise { - const tabs = Object.values(state.tabsByWorktree).flat() - const scans = await Promise.all( - tabs.map(async (tab) => { - const ptyIds = (state.ptyIdsByTabId[tab.id] ?? []).filter( - (ptyId) => ptyIdFilter === null || ptyIdFilter.has(ptyId) - ) - // Why: Codex sessions are not reliably discoverable from tab labels. - // Tabs keep fallback names until a CLI emits an OSC title, and Codex - // does not always do that. The live process tree plus the tab's recorded - // launchAgent are the stable evidence that this pane is running Codex. - return Promise.all( - ptyIds.map(async (ptyId) => { - const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then( - (result) => result, - // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. - () => null - ) - return { - ptyId, - eligible: - inspection !== null && - isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }), - inconclusive: inspection === null || inspection.unavailable === true, - launchedCodex: tab.launchAgent === 'codex', - notified: false - } - }) + const panes = Object.values(state.tabsByWorktree) + .flat() + .flatMap((tab) => + (state.ptyIdsByTabId[tab.id] ?? []) + .filter((ptyId) => args.ptyIdFilter === null || args.ptyIdFilter.has(ptyId)) + .map((ptyId) => ({ tab, ptyId })) + ) + const recordedLanes = await readRecordedCodexPaneLanes(panes.map((pane) => pane.ptyId)) + + // Why: Codex sessions are not reliably discoverable from tab labels. Tabs keep + // fallback names until a CLI emits an OSC title, and Codex does not always do + // that. The live process tree plus the tab's recorded launchAgent are the + // stable evidence that this pane is running Codex. + return Promise.all( + panes.map(async ({ tab, ptyId }) => { + const lane = resolveCodexPaneSelectionLane({ + state, + tab, + ptyId, + recordedLaneKey: recordedLanes[ptyId] + }) + if (!args.isLaneInScope(lane.laneKey)) { + // Why not inconclusive: a pane's lane is fixed at spawn, so this is a + // final answer and the sweep must not spend a retry rung re-asking. + return { + ptyId, + eligible: false, + inconclusive: false, + launchedCodex: false, + notified: false, + laneKey: lane.laneKey, + laneSource: lane.source + } + } + const inspection = await inspectRuntimeTerminalProcess(state.settings, ptyId).then( + (result) => result, + // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes. + () => null ) + return { + ptyId, + eligible: + inspection !== null && + isCodexRestartEligiblePane({ inspection, launchAgent: tab.launchAgent }), + inconclusive: inspection === null || inspection.unavailable === true, + launchedCodex: tab.launchAgent === 'codex', + notified: false, + laneKey: lane.laneKey, + laneSource: lane.source + } }) ) - - return scans.flat() } +/** + * Prompts the panes a just-applied account change stranded. + * + * `target` names the selection slot the change wrote. Panes outside that lane — + * a WSL pane on a host switch, or anything running on a relay/SSH machine — + * never had this account injected, so a notice there is pure damage: it mutes a + * terminal that is working correctly. + */ export async function markLiveCodexSessionsForRestart(args: { previousAccountLabel: string nextAccountLabel: string + target?: CodexAccountSelectionTarget | null + /** Set when the change cleared the selection rather than pointing it somewhere. */ + clearsEveryWslDistro?: boolean }): Promise { const state = useAppStore.getState() - const scans = await scanCodexPanes(state, null) + const scans = await scanCodexPanes(state, { + ptyIdFilter: null, + isLaneInScope: getCodexAccountSwitchLaneMatcher({ + settings: state.settings, + target: args.target, + clearsEveryWslDistro: args.clearsEveryWslDistro + }) + }) const liveCodexSessionPtyIds = scans.filter((scan) => scan.eligible).map((scan) => scan.ptyId) if (liveCodexSessionPtyIds.length === 0) { return @@ -91,12 +177,20 @@ export async function markLiveCodexSessionsForRestart(args: { * * Returns one result per inspected pane so the bind-driven sweep can tell an * answered pane from one whose PTY has not reported a usable process yet. + * + * Scoped to the local host/WSL lanes because the pane-account registry only + * records daemon host spawns: a relay or SSH pane can never be listed stale, so + * inspecting one is a guaranteed-fruitless RPC. listStalePanes then does the + * host-vs-WSL check itself, against each pane's own recorded lane. */ export async function markRestoredStaleCodexSessionsForRestart(args?: { ptyIds?: readonly string[] }): Promise { const state = useAppStore.getState() - const scans = await scanCodexPanes(state, args?.ptyIds ? new Set(args.ptyIds) : null) + const scans = await scanCodexPanes(state, { + ptyIdFilter: args?.ptyIds ? new Set(args.ptyIds) : null, + isLaneInScope: isLocalCodexSelectionLaneKey + }) const liveCodexSessionPtyIds = scans.filter((scan) => scan.eligible).map((scan) => scan.ptyId) if (liveCodexSessionPtyIds.length === 0) { return scans diff --git a/src/renderer/src/lib/codex-stale-pane-sweep.test.ts b/src/renderer/src/lib/codex-stale-pane-sweep.test.ts index 5448315e3ac..251881a57de 100644 --- a/src/renderer/src/lib/codex-stale-pane-sweep.test.ts +++ b/src/renderer/src/lib/codex-stale-pane-sweep.test.ts @@ -353,4 +353,50 @@ describe('notifyCodexPaneBoundForStaleSweep', () => { nextAccountLabel: ACCOUNT_B }) }) + + // Why: recordCodexPaneAccountForSpawn bails on anything that is not a daemon + // HOST spawn, so no remote/SSH pane is ever in the registry and listStalePanes + // can never report one. Every rung one takes is a 15s-timeout RPC spent to + // learn nothing, five times over. + it.each(['remote:env-1@@term-1', 'remote:term-1', 'ssh:my-box@@pty-7'])( + 'never queues %s, so no rung spends an RPC on it', + async (ptyId) => { + useAppStore.setState({ ptyIdsByTabId: { 'tab-1': [ptyId] } }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { ptyId, launchAccountId: 'account-a', activeAccountId: 'account-b' } + ]) + + notifyCodexPaneBoundForStaleSweep(ptyId) + // Why assert the timer before advancing it: the scan would skip this pane + // anyway, so only an unarmed queue proves it was rejected at the door + // rather than costing a flush + scan on every rung. + expect(vi.getTimerCount()).toBe(0) + + // Well past the whole ladder, so this pins "never queued", not "not yet". + await vi.advanceTimersByTimeAsync(120_000) + + expect(vi.getTimerCount()).toBe(0) + expect(window.api.codexAccounts.listStalePanes).not.toHaveBeenCalled() + expect(window.api.pty.inspectProcess).not.toHaveBeenCalled() + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + } + ) + + it('still sweeps the local panes bound alongside a remote one', async () => { + useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'remote:env-1@@term-1'] } }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE]) + + notifyCodexPaneBoundForStaleSweep('remote:env-1@@term-1') + notifyCodexPaneBoundForStaleSweep('pty-1') + await vi.advanceTimersByTimeAsync(300) + + expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledExactlyOnceWith({ + ptyIds: ['pty-1'] + }) + expect(inspectCallCountFor('remote:env-1@@term-1')).toBe(0) + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B + }) + }) }) diff --git a/src/renderer/src/lib/codex-stale-pane-sweep.ts b/src/renderer/src/lib/codex-stale-pane-sweep.ts index 178334643ab..c75cea5fa95 100644 --- a/src/renderer/src/lib/codex-stale-pane-sweep.ts +++ b/src/renderer/src/lib/codex-stale-pane-sweep.ts @@ -2,6 +2,7 @@ import { markRestoredStaleCodexSessionsForRestart, type CodexPaneScanResult } from './codex-session-restart' +import { isForeignMachineCodexPtyId } from './codex-pane-selection-lane' // Why: the first delay coalesces the startup burst of binds and lets // updateTabPtyId (written just after the layout binding) land, since the scan @@ -36,6 +37,12 @@ export function notifyCodexPaneBoundForStaleSweep(ptyId: string): void { if (notifiedPtyIds.has(ptyId)) { return } + // Why: the pane-account registry only records daemon HOST spawns, so a relay + // or SSH pane can never come back stale — every rung it takes is a remote RPC + // (15s timeout) spent to learn nothing. Drop it before it reaches the queue. + if (isForeignMachineCodexPtyId(ptyId)) { + return + } queue(ptyId, SWEEP_ATTEMPT_DELAYS_MS[0]) armForEarliestDue() } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index adf36b1919b..b01292b9bda 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2904,6 +2904,9 @@ function createAccountsApi(): never { // Why: launch accounts are recorded on the host that owns the PTY, which the // web client never is — report no stale panes rather than reject the sweep. listStalePanes: () => Promise.resolve([]), + // Why empty rather than absent: the same host owns both records, so a web + // client has no recorded lane to offer and every pane falls to derivation. + listRecordedPaneLanes: () => Promise.resolve({}), forgetStalePanes: () => Promise.resolve() } as never } diff --git a/src/shared/codex-selection-lane.ts b/src/shared/codex-selection-lane.ts new file mode 100644 index 00000000000..c7b80f671f9 --- /dev/null +++ b/src/shared/codex-selection-lane.ts @@ -0,0 +1,36 @@ +export type CodexAccountSelectionTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type NormalizedCodexAccountSelectionTarget = { + runtime: 'host' | 'wsl' + wslDistro: string | null +} + +export function normalizeCodexAccountSelectionTarget( + target?: CodexAccountSelectionTarget | null +): NormalizedCodexAccountSelectionTarget { + if (target?.runtime === 'wsl') { + return { + runtime: 'wsl', + wslDistro: normalizeWslDistro(target.wslDistro) + } + } + return { runtime: 'host', wslDistro: null } +} + +/** Stable identifier for the selection lane a launch resolves its account from. */ +export function getCodexSelectionLaneKey(target?: CodexAccountSelectionTarget | null): string { + const normalized = normalizeCodexAccountSelectionTarget(target) + return normalized.runtime === 'host' ? 'host' : `wsl:${getWslSelectionKey(normalized.wslDistro)}` +} + +export function getWslSelectionKey(wslDistro: string | null | undefined): string { + return normalizeWslDistro(wslDistro) ?? '__default__' +} + +function normalizeWslDistro(wslDistro: string | null | undefined): string | null { + const trimmed = wslDistro?.trim() + return trimmed ? trimmed : null +} From f10b6de2c799f156ecc77db5a1ed767aadbbc8ca Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:49:18 +0900 Subject: [PATCH 02/53] fix(relay): tolerate wall-clock skew in host-proof validation (#10474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(relay): tolerate wall-clock skew in host-proof validation A few seconds of local clock lag made challenge issuedAt appear in the future, so host-proof rejected every handshake and Mobile Relay looped connecting forever. Allow ±30s skew while keeping the 10s challenge window (#10401). * fix(relay): preserve host-proof challenge bounds --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../runtime/relay/relay-host-proof.test.ts | 225 ++++++++++++++++++ src/main/runtime/relay/relay-host-proof.ts | 10 +- 2 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/relay/relay-host-proof.test.ts diff --git a/src/main/runtime/relay/relay-host-proof.test.ts b/src/main/runtime/relay/relay-host-proof.test.ts new file mode 100644 index 00000000000..39e47e322b2 --- /dev/null +++ b/src/main/runtime/relay/relay-host-proof.test.ts @@ -0,0 +1,225 @@ +import { createHmac, randomBytes } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import nacl from 'tweetnacl' +import { + answerRelayHostChallenge, + type RelayHostChallenge, + type RelayHostProofContext +} from './relay-host-proof' + +const encoder = new TextEncoder() +const HOST_PROOF_DOMAIN = 'orca-relay-host-proof/v1' +const CHALLENGE_DOMAIN = 'orca-relay-host-challenge/v1' +const CLOCK_SKEW_MS = 30_000 + +function concat(parts: readonly Uint8Array[]): Uint8Array { + const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)) + let offset = 0 + for (const part of parts) { + output.set(part, offset) + offset += part.byteLength + } + return output +} + +function uint32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value, false) + return bytes +} + +function uint64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value), false) + return bytes +} + +function field(name: string, value: Uint8Array): Uint8Array { + const encodedName = encoder.encode(name) + return concat([uint32(encodedName.byteLength), encodedName, uint32(value.byteLength), value]) +} + +function text(value: string): Uint8Array { + return encoder.encode(value) +} + +function buildTranscript(input: { + origin: string + relayKey: Uint8Array + nonce: Uint8Array + challengeId: string + issuedAt: number + expiresAt: number + relayHostId: string + hostKey: Uint8Array + userId?: string + profileId?: string + organizationId?: string + assignmentEpoch?: number +}): Uint8Array { + return concat([ + field('protocol', text(HOST_PROOF_DOMAIN)), + field('version', new Uint8Array([1])), + field('relayOrigin', text(input.origin)), + field('relayEphemeralPublicKey', input.relayKey), + field('challengeNonce', input.nonce), + field('challengeId', text(input.challengeId)), + field('issuedAt', uint64(input.issuedAt)), + field('expiresAt', uint64(input.expiresAt)), + field('userId', text(input.userId ?? 'user-1')), + field('profileId', text(input.profileId ?? 'profile-1')), + field('organizationId', text(input.organizationId ?? 'org-1')), + field('relayHostId', text(input.relayHostId)), + field('hostPublicKey', input.hostKey), + field('assignmentEpoch', uint64(input.assignmentEpoch ?? 3)), + field('previousGeneration', new Uint8Array()), + field('resumeRequested', new Uint8Array([0])) + ]) +} + +function buildChallengeFixture(options: { + issuedAt: number + expiresAt: number + localNow: number +}): { + challenge: RelayHostChallenge + context: RelayHostProofContext + expectedProof: string +} { + const hostKeys = nacl.box.keyPair() + const relayKeys = nacl.box.keyPair() + const nonce = randomBytes(24) + const secret = randomBytes(32) + const origin = 'https://c2.relay.onorca.dev' + const relayHostId = 'host-abc123' + const challengeId = 'challenge-skew' + const transcript = buildTranscript({ + origin, + relayKey: relayKeys.publicKey, + nonce, + challengeId, + issuedAt: options.issuedAt, + expiresAt: options.expiresAt, + relayHostId, + hostKey: hostKeys.publicKey + }) + const plaintext = concat([ + text(`${CHALLENGE_DOMAIN}\0`), + uint32(transcript.byteLength), + transcript, + secret + ]) + const challenge: RelayHostChallenge = { + challengeId, + relayEphemeralPublicKeyB64: Buffer.from(relayKeys.publicKey).toString('base64'), + nonceB64: nonce.toString('base64'), + ciphertextB64: Buffer.from( + nacl.box(plaintext, nonce, hostKeys.publicKey, relayKeys.secretKey) + ).toString('base64'), + expiresAt: options.expiresAt + } + const context: RelayHostProofContext = { + relayOrigin: origin, + userId: 'user-1', + profileId: 'profile-1', + organizationId: 'org-1', + relayHostId, + hostPublicKey: hostKeys.publicKey, + hostSecretKey: hostKeys.secretKey, + assignmentEpoch: 3, + resumeRequested: false, + now: () => options.localNow + } + const expectedProof = createHmac('sha256', secret) + .update(text(`${HOST_PROOF_DOMAIN}\0ack\0`)) + .update(transcript) + .digest('base64') + return { challenge, context, expectedProof } +} + +describe('answerRelayHostChallenge clock skew (#10401)', () => { + it('accepts a challenge when local clock is a few seconds behind (issuedAt in the near future)', () => { + const serverNow = 1_700_000_000_000 + const skewBehindMs = 4_400 + const localNow = serverNow - skewBehindMs + const issuedAt = serverNow + const expiresAt = issuedAt + 10_000 + const { challenge, context, expectedProof } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow + }) + + expect(answerRelayHostChallenge(challenge, context)).toBe(expectedProof) + }) + + it('accepts a challenge when local clock is a few seconds ahead of expiresAt', () => { + const serverNow = 1_700_000_000_000 + const issuedAt = serverNow + const expiresAt = issuedAt + 10_000 + const localNow = expiresAt + 4_400 + const { challenge, context, expectedProof } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow + }) + + expect(answerRelayHostChallenge(challenge, context)).toBe(expectedProof) + }) + + it('accepts challenges at the clock-skew boundaries', () => { + const issuedAt = 1_700_000_000_000 + const expiresAt = issuedAt + 10_000 + for (const localNow of [issuedAt - CLOCK_SKEW_MS, expiresAt + CLOCK_SKEW_MS]) { + const { challenge, context, expectedProof } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow + }) + expect(answerRelayHostChallenge(challenge, context)).toBe(expectedProof) + } + }) + + it('still rejects challenges outside the allowed skew window', () => { + const serverNow = 1_700_000_000_000 + const issuedAt = serverNow + const expiresAt = issuedAt + 10_000 + const localNowTooBehind = issuedAt - CLOCK_SKEW_MS - 1 + const behind = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: localNowTooBehind + }) + expect(answerRelayHostChallenge(behind.challenge, behind.context)).toBeNull() + + const localNowTooAhead = expiresAt + CLOCK_SKEW_MS + 1 + const ahead = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: localNowTooAhead + }) + expect(answerRelayHostChallenge(ahead.challenge, ahead.context)).toBeNull() + }) + + it('still rejects an oversized server challenge window', () => { + const issuedAt = 1_700_000_000_000 + const expiresAt = issuedAt + 10_001 + const { challenge, context } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: issuedAt + }) + expect(answerRelayHostChallenge(challenge, context)).toBeNull() + }) + + it('rejects a challenge that expires before it is issued', () => { + const issuedAt = 1_700_000_000_000 + const expiresAt = issuedAt - 1 + const { challenge, context } = buildChallengeFixture({ + issuedAt, + expiresAt, + localNow: issuedAt + }) + expect(answerRelayHostChallenge(challenge, context)).toBeNull() + }) +}) diff --git a/src/main/runtime/relay/relay-host-proof.ts b/src/main/runtime/relay/relay-host-proof.ts index 94db5b2f1b2..da17f8a1576 100644 --- a/src/main/runtime/relay/relay-host-proof.ts +++ b/src/main/runtime/relay/relay-host-proof.ts @@ -3,6 +3,9 @@ import nacl from 'tweetnacl' const HOST_PROOF_TRANSCRIPT_DOMAIN = 'orca-relay-host-proof/v1' const HOST_CHALLENGE_PLAINTEXT_DOMAIN = 'orca-relay-host-challenge/v1' +// Covers routine NTP drift without extending the signed challenge window. +const RELAY_HOST_PROOF_CLOCK_SKEW_MS = 30_000 +const MAX_HOST_PROOF_CHALLENGE_WINDOW_MS = 10_000 const textEncoder = new TextEncoder() const textDecoder = new TextDecoder() @@ -102,9 +105,10 @@ function validateTranscript( context.previousGeneration === undefined ? new Uint8Array() : uint64(context.previousGeneration) return ( issuedAt !== null && - issuedAt <= now && - now <= challenge.expiresAt && - challenge.expiresAt - issuedAt <= 10_000 && + issuedAt - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= now && + now - RELAY_HOST_PROOF_CLOCK_SKEW_MS <= challenge.expiresAt && + issuedAt <= challenge.expiresAt && + challenge.expiresAt - issuedAt <= MAX_HOST_PROOF_CHALLENGE_WINDOW_MS && expiresAt === challenge.expiresAt && equal(fields.get('protocol'), textEncoder.encode(HOST_PROOF_TRANSCRIPT_DOMAIN)) && equal(fields.get('version'), new Uint8Array([1])) && From e104010593e2545c6604aa7014fe6a53492d8da0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:18:29 -0700 Subject: [PATCH 03/53] fix(skills): require updater registration (#11051) * fix(skills): require updater registration * fix(skills): reject incomplete updater registrations --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../skill-freshness-eligibility.test.ts | 24 ++++-- .../skills/skill-freshness-eligibility.ts | 8 +- .../skills/skill-freshness-inventory.test.ts | 35 +++++++++ src/main/skills/skill-freshness-inventory.ts | 9 ++- .../skills/skill-update-registration.test.ts | 75 +++++++++++++++++++ src/main/skills/skill-update-registration.ts | 67 +++++++++++++++++ 6 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 src/main/skills/skill-update-registration.test.ts create mode 100644 src/main/skills/skill-update-registration.ts diff --git a/src/main/skills/skill-freshness-eligibility.test.ts b/src/main/skills/skill-freshness-eligibility.test.ts index cd826298628..ad1517c78a9 100644 --- a/src/main/skills/skill-freshness-eligibility.test.ts +++ b/src/main/skills/skill-freshness-eligibility.test.ts @@ -5,6 +5,12 @@ import { } from '../../shared/skill-freshness' import { eligibleSkillUpdateNames } from './skill-freshness-eligibility' +const globallyUpdatableNames = new Set(['computer-use', 'orca-cli', 'orchestration']) + +function eligible(installations: SkillFreshnessInstallation[]): string[] { + return eligibleSkillUpdateNames(installations, globallyUpdatableNames) +} + function placement( name: string, overrides: Partial = {} @@ -35,7 +41,7 @@ function placement( describe('skill freshness name-scoped update eligibility', () => { it('offers a name when at least one supported placement is outdated and all are official', () => { expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli'), placement('orca-cli', { id: 'orca-cli-claude', @@ -62,7 +68,7 @@ describe('skill freshness name-scoped update eligibility', () => { // update over one refuses work the command could do to a copy that is never at // stake. The canonical copy converges and the outlier is reported separately. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli'), placement('orca-cli', { id: `outlier-${status}-${topology}`, status, topology }) ]) @@ -76,7 +82,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: this is the placement the command writes to, so overwriting it is the // real data-loss case the rail exists to avoid. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli', { id: 'blocked-canonical', status }), placement('orca-cli', { id: 'orca-cli-claude', @@ -93,7 +99,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: a duplicate no longer omits the whole name — the canonical copy converges // and the duplicate row is flagged as maybe-not-reached rather than blocking. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli'), placement('orca-cli', { id: 'orca-cli-gemini', @@ -112,7 +118,7 @@ describe('skill freshness name-scoped update eligibility', () => { // name here advertises an update the command reports as already up to date, so the // badge could never clear; the dialog explains the duplicate as skipped instead. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orchestration', { status: 'current' }), placement('orchestration', { id: 'orchestration-factory', @@ -131,7 +137,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: with no canonical or alias to anchor `--global`, the command has no // reliable target, so a duplicate-only skill stays unoffered. expect( - eligibleSkillUpdateNames([ + eligible([ placement('orca-cli', { rootId: 'home-gemini', unresolvedPath: '/home/.gemini/skills/orca-cli', @@ -147,7 +153,7 @@ describe('skill freshness name-scoped update eligibility', () => { // Why: a project copy is never written by `--global`, so it does not speak for // the global one — while a name whose convergent copy is current stays unoffered. expect( - eligibleSkillUpdateNames([ + eligible([ placement('computer-use', { status: 'current' }), placement('orchestration'), placement('orchestration', { @@ -159,6 +165,10 @@ describe('skill freshness name-scoped update eligibility', () => { ).toEqual(['orchestration']) }) + it('does not offer an official canonical copy missing from the updater lock (#10791)', () => { + expect(eligibleSkillUpdateNames([placement('orca-cli')], new Set())).toEqual([]) + }) + it('builds only an explicit, deterministic global command', () => { expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe( 'npx skills update orca-cli orchestration --global' diff --git a/src/main/skills/skill-freshness-eligibility.ts b/src/main/skills/skill-freshness-eligibility.ts index 76e21611693..52517132e1c 100644 --- a/src/main/skills/skill-freshness-eligibility.ts +++ b/src/main/skills/skill-freshness-eligibility.ts @@ -13,9 +13,12 @@ import { * do, or refuse work it could, over a copy that is never at stake either way. A * blocked *convergent* copy still withholds it, because that is the placement the * command would write to and overwriting it is the real data-loss case. + * `globallyUpdatableNames` comes from the updater's lock; an unregistered copied + * bundle is installed but `skills update` cannot identify its source. */ export function eligibleSkillUpdateNames( - installations: readonly SkillFreshnessInstallation[] + installations: readonly SkillFreshnessInstallation[], + globallyUpdatableNames: ReadonlySet ): string[] { const byName = new Map() for (const installation of installations) { @@ -26,6 +29,9 @@ export function eligibleSkillUpdateNames( const eligible: string[] = [] for (const [, entries] of byName) { + if (!globallyUpdatableNames.has(entries[0].name)) { + continue + } const convergent = entries.filter((entry) => SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) ) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index c04616abb3a..cdf60c6420a 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -58,6 +58,21 @@ async function fixture() { ...snapshots[1] } await Promise.all([ + mkdir(join(homeDir, '.agents'), { recursive: true }).then(() => + writeFile( + join(homeDir, '.agents', '.skill-lock.json'), + `${JSON.stringify({ + version: 3, + skills: { + 'orca-cli': { + skillFolderHash: 'tracked-old-hash', + skillPath: 'skills/orca-cli/SKILL.md', + source: 'stablyai/orca' + } + } + })}\n` + ) + ), writeFile( join(skillResourceRoot, 'current-manifest.json'), `${JSON.stringify({ schemaVersion: 2, skills: [current] }, null, 2)}\n` @@ -121,6 +136,26 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual(['orca-cli']) }) + it('does not offer an older copied bundle the external updater has never registered (#10791)', async () => { + const test = await fixture() + await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) + await rm(join(test.homeDir, '.agents', '.skill-lock.json')) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot, + stateHome: null + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'outdated' + }) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + it('labels newer known and unrecognized bytes honestly without calling them modified', async () => { const test = await fixture() await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index 97527906d1f..5982c878fc1 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -17,6 +17,7 @@ import { type CandidateLstat } from './skill-freshness-placement-observation' import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' +import { readGloballyUpdatableSkillNames } from './skill-update-registration' export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 @@ -39,8 +40,12 @@ export async function inventorySkillFreshness(args: { repos?: Repo[] resourceRoot?: string candidateLstat?: CandidateLstat + stateHome?: string | null }): Promise { - const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) + const [artifacts, globallyUpdatableNames] = await Promise.all([ + loadSkillBundleArtifacts(args.resourceRoot), + readGloballyUpdatableSkillNames({ homeDir: args.homeDir, stateHome: args.stateHome }) + ]) const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) const discoveryArgs = { homeDir: args.homeDir, @@ -163,7 +168,7 @@ export async function inventorySkillFreshness(args: { return { schemaVersion: 1, installations, - eligibleUpdateNames: eligibleSkillUpdateNames(installations), + eligibleUpdateNames: eligibleSkillUpdateNames(installations, globallyUpdatableNames), scanIssues, scannedAt: Date.now() } diff --git a/src/main/skills/skill-update-registration.test.ts b/src/main/skills/skill-update-registration.test.ts new file mode 100644 index 00000000000..6ddc60bf41a --- /dev/null +++ b/src/main/skills/skill-update-registration.test.ts @@ -0,0 +1,75 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { readGloballyUpdatableSkillNames } from './skill-update-registration' + +const temporaryDirectories: string[] = [] + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-registration-')) + temporaryDirectories.push(root) + return root +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) +}) + +describe('global skill update registration', () => { + it('reads only updateable entries from the external updater lock', async () => { + const homeDir = await temporaryRoot() + await mkdir(join(homeDir, '.agents'), { recursive: true }) + await writeFile( + join(homeDir, '.agents', '.skill-lock.json'), + JSON.stringify({ + version: 3, + skills: { + orchestration: { + skillFolderHash: 'hash', + skillPath: 'skills/orchestration/SKILL.md', + source: 'stablyai/orca' + }, + copied: {}, + emptyHash: { + skillFolderHash: '', + skillPath: 'skills/empty-hash/SKILL.md', + source: 'stablyai/orca' + }, + emptyPath: { + skillFolderHash: 'hash', + skillPath: '', + source: 'stablyai/orca' + } + } + }) + ) + + await expect(readGloballyUpdatableSkillNames({ homeDir, stateHome: null })).resolves.toEqual( + new Set(['orchestration']) + ) + }) + + it('uses the XDG state lock when configured', async () => { + const root = await temporaryRoot() + const stateHome = join(root, 'state') + await mkdir(join(stateHome, 'skills'), { recursive: true }) + await writeFile( + join(stateHome, 'skills', '.skill-lock.json'), + JSON.stringify({ + version: 3, + skills: { + 'orca-cli': { + skillFolderHash: 'hash', + skillPath: 'skills/orca-cli/SKILL.md', + source: 'stablyai/orca' + } + } + }) + ) + + await expect(readGloballyUpdatableSkillNames({ homeDir: root, stateHome })).resolves.toEqual( + new Set(['orca-cli']) + ) + }) +}) diff --git a/src/main/skills/skill-update-registration.ts b/src/main/skills/skill-update-registration.ts new file mode 100644 index 00000000000..658679dacd4 --- /dev/null +++ b/src/main/skills/skill-update-registration.ts @@ -0,0 +1,67 @@ +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const GLOBAL_SKILL_LOCK_SCHEMA_VERSION = 3 + +type SkillUpdateRegistrationArgs = { + homeDir?: string + stateHome?: string | null +} + +function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string { + const stateHome = + args.stateHome === undefined + ? args.homeDir === undefined + ? (process.env.XDG_STATE_HOME ?? null) + : null + : args.stateHome + return stateHome + ? join(stateHome, 'skills', '.skill-lock.json') + : join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json') +} + +export async function readGloballyUpdatableSkillNames( + args: SkillUpdateRegistrationArgs = {} +): Promise> { + try { + const parsed = JSON.parse(await readFile(globalSkillLockPath(args), 'utf8')) as { + version?: unknown + skills?: unknown + } + if ( + typeof parsed.version !== 'number' || + parsed.version < GLOBAL_SKILL_LOCK_SCHEMA_VERSION || + !parsed.skills || + typeof parsed.skills !== 'object' || + Array.isArray(parsed.skills) + ) { + return new Set() + } + + return new Set( + Object.entries(parsed.skills) + .filter(([, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entry = value as { + skillFolderHash?: unknown + skillPath?: unknown + source?: unknown + } + return ( + typeof entry.skillFolderHash === 'string' && + entry.skillFolderHash.length > 0 && + typeof entry.skillPath === 'string' && + entry.skillPath.length > 0 && + typeof entry.source === 'string' && + entry.source.length > 0 + ) + }) + .map(([name]) => name) + ) + } catch { + return new Set() + } +} From 9a3b348e826e6702071eff7469570a29217258ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:28:33 +0000 Subject: [PATCH 04/53] release: v1.4.160-rc.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8c6fe214781..2e9bf6d1604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.160-rc.2", + "version": "1.4.160-rc.3", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From badf91101babf96fa09cb79a8294f7e23b9f081c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:54:02 -0700 Subject: [PATCH 05/53] fix(quality): enforce performance-safe lint baseline (#11074) * fix(quality): clear safe existing lint findings * fix(quality): keep lint cleanup allocation-free * fix(quality): enforce performance-safe baseline * test(terminal): drain deferred confirmation cleanup --- .github/workflows/pr.yml | 3 + config/oxlint-code-quality-type-aware.json | 11 +- config/oxlint-code-quality.json | 17 +- .../scripts/claude-usage-yield-benchmark.mjs | 3 +- .../cli-runtime-client-deferral-benchmark.mjs | 1 + .../linux-wayland-terminal-exercise.mjs | 2 +- ...node-pty-console-list-agent-patch.test.mjs | 5 +- .../publish-complete-draft-releases.mjs | 2 +- config/scripts/run-idle-cpu-benchmark.mjs | 2 +- .../app/h/[hostId]/session/[worktreeId].tsx | 7 +- mobile/app/h/[hostId]/tasks.tsx | 6 +- .../session/use-pr-bot-author-overrides.ts | 3 +- package.json | 6 +- src/cli/args.ts | 27 +-- src/cli/browser.test.ts | 2 +- src/cli/command-spec.ts | 18 ++ src/cli/command-suggestion.ts | 3 +- src/cli/handlers/agent-hooks.ts | 8 +- src/cli/handlers/environment.ts | 3 +- src/cli/handlers/vm.ts | 6 +- src/cli/runtime/transport.ts | 2 +- .../remote-hook-service-installers.test.ts | 39 ++-- .../session-scanner-opencode-sqlite-list.ts | 6 +- .../ai-vault/session-scanner-record-value.ts | 5 + .../ai-vault/session-scanner-token-values.ts | 2 +- src/main/ai-vault/session-scanner-types.ts | 2 +- src/main/ai-vault/session-scanner-values.ts | 9 +- src/main/browser/agent-browser-bridge.test.ts | 82 +++++---- src/main/browser/browser-cookie-import.ts | 18 +- src/main/browser/browser-manager.ts | 7 +- .../browser/cdp-bridge-integration.test.ts | 2 +- src/main/claude-accounts/keychain.ts | 4 +- src/main/cli/cli-installer.ts | 2 +- .../codex/hook-service-trust-grant.test.ts | 4 +- .../daemon/degraded-daemon-pty-provider.ts | 12 +- src/main/daemon/history-manager.ts | 2 +- src/main/github/pr-head-tracking-ref.ts | 2 +- src/main/gitlab/mr-head-tracking-ref.ts | 2 +- src/main/gitlab/work-item-details.ts | 2 +- src/main/index.ts | 18 +- src/main/ipc/ai-vault.ts | 27 ++- src/main/ipc/diagnostics.ts | 3 +- src/main/ipc/ephemeral-vm.ts | 8 +- src/main/ipc/github.ts | 4 +- src/main/ipc/pty.test.ts | 6 +- src/main/ipc/register-core-handlers.ts | 7 +- src/main/ipc/ssh-browse.ts | 2 +- src/main/ipc/ssh.test.ts | 2 +- src/main/ipc/ssh.ts | 3 +- src/main/ipc/telemetry.ts | 3 +- src/main/ipc/worktree-remote.ts | 10 +- src/main/persistence.ts | 19 +- .../plugins/plugin-content-pack-registry.ts | 9 +- src/main/providers/local-pty-provider.ts | 3 +- src/main/providers/ssh-git-provider.test.ts | 8 +- src/main/rate-limits/claude-fetcher.ts | 4 +- src/main/runtime/orca-runtime-files.ts | 2 +- src/main/runtime/orca-runtime.ts | 170 +++++++++--------- src/main/runtime/orchestration/coordinator.ts | 4 +- .../runtime/rpc/methods/agent-session.test.ts | 6 +- src/main/runtime/rpc/methods/ai-vault.ts | 3 +- .../methods/file-watch-stream-lifecycle.ts | 4 +- src/main/runtime/rpc/methods/native-chat.ts | 7 +- src/main/runtime/runtime-rpc.test.ts | 4 +- src/main/ssh/relay-protocol.ts | 2 +- src/main/ssh/ssh-connection.test.ts | 2 +- src/main/ssh/ssh-connection.ts | 2 - src/preload/api-types.ts | 24 ++- src/preload/index.ts | 43 +++-- src/relay/protocol.ts | 2 +- src/relay/relay.ts | 2 +- .../NewWorkspaceComposerCard.test.tsx | 3 +- .../components/NewWorkspaceComposerCard.tsx | 9 +- src/renderer/src/components/TaskPage.tsx | 4 +- .../AgentSessionContinuationDialog.test.tsx | 3 +- .../use-hold-dictation-gesture.test.tsx | 3 +- .../editor/ChangesModeView.test.tsx | 3 +- .../src/components/editor/MonacoEditor.tsx | 3 +- .../editor/ReviewNotesSendMenuContent.tsx | 7 +- .../editor/markdown-round-trip.test.ts | 6 +- .../editor/useEditorPanelContentState.ts | 2 +- .../use-feature-wall-tour-telemetry.ts | 6 +- .../FloatingTerminalWindowControls.tsx | 9 +- .../slug-dialog/SlugDialogBody.tsx | 3 +- .../components/mobile/MobilePageContent.tsx | 10 +- ...-native-chat-composer-attachments.test.tsx | 3 +- .../use-native-chat-composer-paste.test.tsx | 3 +- ...-native-chat-external-attachments.test.tsx | 3 +- .../use-onboarding-flow-persistence.test.ts | 3 +- .../SourceControlAgentActionDialog.test.tsx | 3 +- .../right-sidebar/checks-panel-content.tsx | 4 +- .../src/components/right-sidebar/index.tsx | 3 +- ...ce-control-create-review-blocked-action.ts | 1 + .../source-control-entry-context-menu.tsx | 5 +- .../use-hosted-review-actions.ts | 3 +- .../useCreatePullRequestDialogFields.test.ts | 3 +- .../useCreatePullRequestDialogFields.ts | 3 +- .../settings/MobileEmulatorSettingsPane.tsx | 2 +- .../settings/RepositoryHooksSection.test.ts | 3 +- .../settings/TerminalAppearanceSection.tsx | 3 +- .../settings/VoiceSpeechModelSection.tsx | 7 +- .../repository-source-control-ai-labels.ts | 6 +- .../settings/settings-search-keywords.ts | 3 +- .../components/sidebar/SidebarNav.test.tsx | 3 +- ....compact-ports-hover-independence.test.tsx | 3 +- .../components/sidebar/WorktreeOpenInMenu.tsx | 7 +- .../src/components/status-bar/tooltip.tsx | 82 +++++---- .../components/tab-bar/QuickLaunchButton.tsx | 9 +- .../src/components/tab-bar/TabBar.tsx | 2 +- .../src/components/tab-group/tab-drag-data.ts | 56 ++++++ .../tab-group/tab-drag-preview-target.ts | 3 +- .../src/components/tab-group/tab-drop-zone.ts | 2 +- .../tab-group/tab-group-panel-split-target.ts | 2 +- .../src/components/tab-group/tab-insertion.ts | 2 +- .../components/tab-group/useTabDragSplit.ts | 88 ++------- .../terminal-pane/keyboard-handlers.ts | 1 + .../terminal-pane/pty-connection.test.ts | 2 +- .../useSessionRestoredBannerDismiss.test.tsx | 3 +- .../TerminalQuickCommandDialog.tsx | 4 +- src/renderer/src/hooks/useIpcEvents.ts | 6 +- src/renderer/src/lib/agent-paste-draft.ts | 3 +- .../src/lib/ephemeral-vm-workspace-target.ts | 6 +- .../lib/github-work-item-background-create.ts | 3 +- .../src/lib/open-in-application-selection.ts | 3 + .../lib/pane-manager/pane-manager-types.ts | 3 +- src/renderer/src/lib/repo-slug-index.ts | 3 +- .../src/lib/worktree-operation-generation.ts | 6 +- .../src/runtime/web-runtime-session.ts | 6 +- .../github-pr-refresh-host-guard.test.ts | 7 +- .../src/store/slices/runtime-status.test.ts | 7 +- src/renderer/src/web/web-preload-api.ts | 2 +- src/shared/agent-hook-listener.ts | 2 + src/shared/agent-session-resume.ts | 1 + src/shared/ai-vault-types.ts | 2 + src/shared/ephemeral-vm-recipes.test.ts | 6 +- src/shared/ephemeral-vm-recipes.ts | 10 -- src/shared/external-worktree-inbox.ts | 2 +- src/shared/external-worktree-visibility.ts | 25 +++ .../remote-runtime-shared-control-protocol.ts | 3 +- src/shared/runtime-types.ts | 4 +- src/shared/worktree-ownership.ts | 34 +--- .../fixtures/visible-tui-scroll-fixture.cjs | 2 +- ...codex-skill-preview-artifact-repro.spec.ts | 3 +- 143 files changed, 679 insertions(+), 623 deletions(-) create mode 100644 src/cli/command-spec.ts create mode 100644 src/main/ai-vault/session-scanner-record-value.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-data.ts create mode 100644 src/renderer/src/lib/open-in-application-selection.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3306575661d..f51e54b9620 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,6 +32,9 @@ jobs: - name: Lint run: pnpm exec oxlint --format github + - name: Enforce full code-quality baseline + run: pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware + - name: Check switch exhaustiveness run: pnpm run lint:switch-exhaustiveness diff --git a/config/oxlint-code-quality-type-aware.json b/config/oxlint-code-quality-type-aware.json index 409215908b4..9198df65775 100644 --- a/config/oxlint-code-quality-type-aware.json +++ b/config/oxlint-code-quality-type-aware.json @@ -12,11 +12,16 @@ }, "rules": { "typescript/await-thenable": "warn", - "typescript/no-floating-promises": "warn", - "typescript/no-misused-promises": "warn", - "typescript/only-throw-error": "warn", "typescript/restrict-plus-operands": "warn", "typescript/restrict-template-expressions": "warn" }, + "overrides": [ + { + "files": ["**/*.test.*", "**/*.spec.*"], + "rules": { + "typescript/await-thenable": "off" + } + } + ], "ignorePatterns": ["**/node_modules", "**/dist", "**/out"] } diff --git a/config/oxlint-code-quality.json b/config/oxlint-code-quality.json index 3ab7f3baed0..bfa1c115c45 100644 --- a/config/oxlint-code-quality.json +++ b/config/oxlint-code-quality.json @@ -10,9 +10,6 @@ "restriction": "off", "nursery": "off" }, - "options": { - "reportUnusedDisableDirectives": "warn" - }, "jsPlugins": [ { "name": "app-store-performance", @@ -29,15 +26,7 @@ "import/no-cycle": ["warn", { "maxDepth": 3 }], "import/no-duplicates": "warn", "import/no-self-import": "warn", - "max-params": ["warn", 5], - "no-fallthrough": "warn", - "no-loop-func": "warn", - "no-promise-executor-return": "warn", - "no-unmodified-loop-condition": "warn", - "preserve-caught-error": "warn", - "promise/no-multiple-resolved": "warn", - "react/no-unstable-nested-components": "warn", - "react/react-compiler": "warn" + "no-fallthrough": "warn" }, "overrides": [ { @@ -55,9 +44,7 @@ "rules": { "vitest/no-conditional-tests": "warn", "vitest/no-focused-tests": "warn", - "vitest/no-identical-title": "warn", - "vitest/valid-expect": "warn", - "vitest/valid-title": "warn" + "vitest/no-identical-title": "warn" } }, { diff --git a/config/scripts/claude-usage-yield-benchmark.mjs b/config/scripts/claude-usage-yield-benchmark.mjs index c587476268e..aea601c58e5 100644 --- a/config/scripts/claude-usage-yield-benchmark.mjs +++ b/config/scripts/claude-usage-yield-benchmark.mjs @@ -12,11 +12,10 @@ // other work would be a regression, not a win. // // Run with: node config/scripts/claude-usage-yield-benchmark.mjs -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' -import { readdirSync, statSync } from 'node:fs' const REPO_ROOT = new URL('../..', import.meta.url) const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10') diff --git a/config/scripts/cli-runtime-client-deferral-benchmark.mjs b/config/scripts/cli-runtime-client-deferral-benchmark.mjs index 4a7560fc7dd..c322410b253 100644 --- a/config/scripts/cli-runtime-client-deferral-benchmark.mjs +++ b/config/scripts/cli-runtime-client-deferral-benchmark.mjs @@ -196,6 +196,7 @@ try { // Each case is (label, argv, env). The runtime-dependent ones point at an // empty user-data dir so both arms get the same deterministic answer. const isolated = { ORCA_USER_DATA_PATH: userDataPath } + /** @type {Array<[string, string[], Record]>} */ const cases = [ ['orca --help', ['--help'], {}], ['orca help worktree', ['help', 'worktree'], {}], diff --git a/config/scripts/linux-wayland-terminal-exercise.mjs b/config/scripts/linux-wayland-terminal-exercise.mjs index 65ae598db92..4a6b942f984 100644 --- a/config/scripts/linux-wayland-terminal-exercise.mjs +++ b/config/scripts/linux-wayland-terminal-exercise.mjs @@ -200,7 +200,7 @@ export async function setupTerminal(page, repoPath, logPhase) { return pane?.container?.dataset?.ptyId ?? null }) ) - logPhase('setup.pty-bound', `ptyId=${ptyId}`) + logPhase('setup.pty-bound', `ptyId=${String(ptyId)}`) return ptyId } diff --git a/config/scripts/node-pty-console-list-agent-patch.test.mjs b/config/scripts/node-pty-console-list-agent-patch.test.mjs index c6c8677a6e9..07e97c54832 100644 --- a/config/scripts/node-pty-console-list-agent-patch.test.mjs +++ b/config/scripts/node-pty-console-list-agent-patch.test.mjs @@ -40,7 +40,10 @@ describe('Windows SSH relay node-pty console-list patch', () => { const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource()) patchNodePtyConsoleListAgent(tamperedPatch.root) - writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`) + writeFileSync( + tamperedPatch.agentPath, + `${readFileSync(tamperedPatch.agentPath, 'utf8')}\n// drift` + ) expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed') }) }) diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs index a02431afe88..ad66c8cfbf7 100644 --- a/config/scripts/publish-complete-draft-releases.mjs +++ b/config/scripts/publish-complete-draft-releases.mjs @@ -98,7 +98,7 @@ export async function publishCompleteDraftReleases({ for (const release of candidates) { const tag = release.tag_name - if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) { + if (!(await Promise.resolve(isDraftBuiltFromCurrentRef({ tag, release })))) { const reason = 'tag is not built from the current release ref' skipped.push({ tag, reason }) log(`Skipping stale RC draft release ${tag}: ${reason}`) diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index 2e5112b011d..baa061b3590 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -572,7 +572,7 @@ async function main() { if (options.output) { mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }) writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`) - console.log(`[idle-cpu] wrote ${options.output}`) + console.log(`[idle-cpu] wrote ${String(options.output)}`) } console.log( JSON.stringify( diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0b4b11b4449..7e4ac17933f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Animated, AppState, Linking, type AppStateStatus } from 'react-native' -import * as Clipboard from 'expo-clipboard' import { + Animated, + AppState, + Linking, + type AppStateStatus, BackHandler, FlatList, Image, @@ -17,6 +19,7 @@ import { type LayoutChangeEvent, type ListRenderItem } from 'react-native' +import * as Clipboard from 'expo-clipboard' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import AsyncStorage from '@react-native-async-storage/async-storage' diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index a9d24ae637a..f3980bb31b8 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -106,11 +106,9 @@ import { } from '../../../src/tasks/setup-hook-trust' import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { triggerMediumImpact } from '../../../src/platform/haptics' -import type { - GitHubProjectSortDirection, - GitHubProjectTable as SharedGitHubProjectTable -} from '../../../src/tasks/mobile-github-project-group-sort' import { + type GitHubProjectSortDirection, + type GitHubProjectTable as SharedGitHubProjectTable, groupRows, isIterationCurrent, sortRows, diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts index 4d9166c39af..94e39794616 100644 --- a/mobile/src/session/use-pr-bot-author-overrides.ts +++ b/mobile/src/session/use-pr-bot-author-overrides.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import type { ConnectionState } from '../transport/types' -import type { RpcSuccess } from '../transport/types' +import type { ConnectionState, RpcSuccess } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides' diff --git a/package.json b/package.json index 2e9bf6d1604..e21ee6be8dd 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", - "audit:code-quality:native": "oxlint --config config/oxlint-code-quality.json --report-unused-disable-directives-severity warn src config tests mobile", - "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests mobile", + "audit:code-quality:native": "oxlint --config config/oxlint-code-quality.json src config tests mobile --deny-warnings", + "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", "audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none", "check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs", "check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs", diff --git a/src/cli/args.ts b/src/cli/args.ts index 90a42ac813b..3d37517e795 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,5 +1,9 @@ import { RuntimeClientError } from './runtime/types' import { unknownCommandData, unknownFlagData } from './command-suggestion' +import { specPaths, type CommandSpec } from './command-spec' + +export { specPaths } +export type { CommandSpec } export type ParsedArgs = { commandPath: string[] @@ -7,23 +11,6 @@ export type ParsedArgs = { positionalFlagConflicts?: string[] } -export type CommandSpec = { - path: string[] - // Why: conventional alternate verbs should resolve without duplicating specs - // or handler registrations. - aliases?: string[][] - argumentMode?: 'parsed' | 'passthrough' - // Why: irreversibly destroys persistent state — typo recovery must not steer a - // benign mistake into one of these via the agent nextSteps channel. #6303 - destructive?: boolean - summary: string - usage: string - allowedFlags: string[] - positionalArgs?: string[] - examples?: string[] - notes?: string[] -} - export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment'] const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment']) export const BOOLEAN_FLAGS = new Set([ @@ -157,12 +144,6 @@ export function matches(actual: string[], expected: string[]): boolean { ) } -// Why: a spec is reachable by its canonical path plus any declared aliases — one -// definition so resolution, validation, help, and agent-context never disagree. -export function specPaths(spec: CommandSpec): string[][] { - return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] -} - export function supportsBrowserPageFlag(commandPath: string[]): boolean { const joined = commandPath.join(' ') if (['open', 'status'].includes(commandPath[0])) { diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 83ca43d4859..f622f9d79b1 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -293,7 +293,7 @@ describe('orca cli browser page targeting', () => { }) }) -describe('orca cli browser tab profiles', () => { +describe('orca cli browser profile management', () => { beforeEach(() => { callMock.mockReset() }) diff --git a/src/cli/command-spec.ts b/src/cli/command-spec.ts new file mode 100644 index 00000000000..1719fbd96fe --- /dev/null +++ b/src/cli/command-spec.ts @@ -0,0 +1,18 @@ +export type CommandSpec = { + path: string[] + // Why: conventional alternate verbs should resolve without duplicating specs or handlers. + aliases?: string[][] + argumentMode?: 'parsed' | 'passthrough' + // Why: typo recovery must never steer a benign mistake into destructive state changes. + destructive?: boolean + summary: string + usage: string + allowedFlags: string[] + positionalArgs?: string[] + examples?: string[] + notes?: string[] +} + +export function specPaths(spec: CommandSpec): string[][] { + return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] +} diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index 530d9baf78a..db481de6ea8 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -1,5 +1,4 @@ -import type { CommandSpec } from './args' -import { specPaths } from './args' +import { specPaths, type CommandSpec } from './command-spec' // Why: rank the live registry so typo recovery cannot drift from accepted paths. diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2b83f5c15ea..03f9c24c9f3 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -4,7 +4,12 @@ import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import type { CommandHandler } from '../dispatch' import { printResult } from '../format' -import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from '../runtime-client' +import { + RuntimeClientError, + type RuntimeClient, + type RuntimeRpcSuccess, + getDefaultUserDataPath +} from '../runtime-client' import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/types' @@ -12,7 +17,6 @@ import { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } from '../../main/agent-hooks/managed-agent-hook-controls' -import { getDefaultUserDataPath } from '../runtime-client' type AgentHookCommandResult = { enabled: boolean diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts index 08530ea29c3..7827f91347d 100644 --- a/src/cli/handlers/environment.ts +++ b/src/cli/handlers/environment.ts @@ -1,8 +1,7 @@ import type { CommandHandler } from '../dispatch' import { formatEnvironment, formatEnvironmentList, printResult } from '../format' -import { getDefaultUserDataPath } from '../runtime-client' +import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client' import type { RuntimeRpcSuccess } from '../runtime-client' -import { RuntimeClientError } from '../runtime-client' import { redactRuntimeEnvironment } from '../../shared/runtime-environments' import { addEnvironmentFromPairingCode, diff --git a/src/cli/handlers/vm.ts b/src/cli/handlers/vm.ts index aaf01b08872..e000ec15afe 100644 --- a/src/cli/handlers/vm.ts +++ b/src/cli/handlers/vm.ts @@ -5,11 +5,13 @@ import { RuntimeClientError } from '../runtime-client' import { parseOrcaYaml } from '../../shared/orca-yaml' import { getEphemeralVmRecipeResultProjectRoot, - getEphemeralVmRecipeResultWarnings, - redactEphemeralVmRecipeDiagnosticText, type EphemeralVmRecipeDoctorCheck, type EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' +import { + getEphemeralVmRecipeResultWarnings, + redactEphemeralVmRecipeDiagnosticText +} from '../../shared/ephemeral-vm-recipe-diagnostics' // Why: import directly from the doctor module (not the barrel) — it uses Node // fs/path and must stay out of the browser bundle that imports the barrel. import { doctorEphemeralVmRecipe } from '../../shared/ephemeral-vm-recipe-doctor' diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index fc6c3339ded..b88f3cf3227 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -88,7 +88,7 @@ export async function sendRequest( ) }) }) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk // Why: the server may interleave `{"_keepalive":true}\n` frames with the // final success/failure frame to keep both idle timers alive during a diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 3acca6a64ef..89124fe86a8 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -7,33 +7,20 @@ vi.mock('electron', () => ({ } })) -import { CodexHookService } from '../codex/hook-service' -import { DroidHookService } from '../droid/hook-service' -import { CursorHookService } from '../cursor/hook-service' -import { CommandCodeHookService } from '../command-code/hook-service' -import { GeminiHookService } from '../gemini/hook-service' -import { AntigravityHookService } from '../antigravity/hook-service' -import { AmpHookService } from '../amp/hook-service' -import { ClaudeHookService } from '../claude/hook-service' -import { GrokHookService } from '../grok/hook-service' -import { CopilotHookService } from '../copilot/hook-service' -import { HermesHookService } from '../hermes/hook-service' -import { DevinHookService } from '../devin/hook-service' -import { KimiHookService } from '../kimi/hook-service' +import { CodexHookService, codexHookService } from '../codex/hook-service' +import { DroidHookService, droidHookService } from '../droid/hook-service' +import { CursorHookService, cursorHookService } from '../cursor/hook-service' +import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service' +import { GeminiHookService, geminiHookService } from '../gemini/hook-service' +import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service' +import { AmpHookService, ampHookService } from '../amp/hook-service' +import { ClaudeHookService, claudeHookService } from '../claude/hook-service' +import { GrokHookService, grokHookService } from '../grok/hook-service' +import { CopilotHookService, copilotHookService } from '../copilot/hook-service' +import { HermesHookService, hermesHookService } from '../hermes/hook-service' +import { DevinHookService, devinHookService } from '../devin/hook-service' +import { KimiHookService, kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' -import { ampHookService } from '../amp/hook-service' -import { antigravityHookService } from '../antigravity/hook-service' -import { claudeHookService } from '../claude/hook-service' -import { codexHookService } from '../codex/hook-service' -import { copilotHookService } from '../copilot/hook-service' -import { cursorHookService } from '../cursor/hook-service' -import { droidHookService } from '../droid/hook-service' -import { commandCodeHookService } from '../command-code/hook-service' -import { geminiHookService } from '../gemini/hook-service' -import { devinHookService } from '../devin/hook-service' -import { grokHookService } from '../grok/hook-service' -import { hermesHookService } from '../hermes/hook-service' -import { kimiHookService } from '../kimi/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { installRemoteManagedAgentHooks, diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index b89b97b229e..5d6c6202824 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -1,6 +1,8 @@ import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' -import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths' -import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' +import { + buildOpenCodeSqliteCandidatePath, + splitOpenCodeSqliteCandidate +} from './session-scanner-opencode-sqlite-paths' import type { SessionFileCandidate } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import SyncDatabase from '../sqlite/sync-database' diff --git a/src/main/ai-vault/session-scanner-record-value.ts b/src/main/ai-vault/session-scanner-record-value.ts new file mode 100644 index 00000000000..bd2c9b6326d --- /dev/null +++ b/src/main/ai-vault/session-scanner-record-value.ts @@ -0,0 +1,5 @@ +export function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} diff --git a/src/main/ai-vault/session-scanner-token-values.ts b/src/main/ai-vault/session-scanner-token-values.ts index e2621b9f491..0c30558ff75 100644 --- a/src/main/ai-vault/session-scanner-token-values.ts +++ b/src/main/ai-vault/session-scanner-token-values.ts @@ -1,5 +1,5 @@ import type { CodexUsageSnapshot } from './session-scanner-types' -import { asRecord } from './session-scanner-values' +import { asRecord } from './session-scanner-record-value' export function tokenTotal(value: unknown): number { const usage = asRecord(value) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index f9af3f20897..ac94a067d2a 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -1,5 +1,5 @@ -import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { + AiVaultAgent, AiVaultScanIssue, AiVaultSession, AiVaultSessionPreviewMessage diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 39006bd68a4..1deb8bf5dc9 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -1,6 +1,9 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { readFile } from 'node:fs/promises' +import { asRecord } from './session-scanner-record-value' + +export { asRecord } export function timestampMs(value: unknown): number { if (typeof value === 'string') { @@ -25,12 +28,6 @@ export function parseJsonObject(line: string): Record | null { } } -export function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null -} - export function extractString(value: unknown): string | null { if (typeof value !== 'string') { return null diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index dc804a2dbe1..b9f5a9bd92d 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -63,6 +63,8 @@ import { CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR } from '../../shared/clipboard-text' +type ExecFileCallback = (error: unknown, stdout?: string, stderr?: string) => void + // Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId // inside a try/catch. Override the private method to inject our mock. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -116,31 +118,37 @@ function mockWebContents(id: number, url = 'https://example.com', title = 'Examp } function succeedWith(data: unknown): void { - execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => { - cb(null, JSON.stringify({ success: true, data }), '') - return { - stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + execFileMock.mockImplementation( + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + cb(null, JSON.stringify({ success: true, data }), '') + return { + stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + } } - }) + ) } function succeedForContentEditable(data: unknown = { ok: true }): void { - execFileMock.mockImplementation((_bin: string, args: string[], _opts: unknown, cb: Function) => { - const result = - args.includes('get') && args.includes('attr') && args.includes('contenteditable') - ? { value: 'true' } - : data - cb(null, JSON.stringify({ success: true, data: result }), '') - return { - stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + execFileMock.mockImplementation( + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { + const result = + args.includes('get') && args.includes('attr') && args.includes('contenteditable') + ? { value: 'true' } + : data + cb(null, JSON.stringify({ success: true, data: result }), '') + return { + stdin: { on: vi.fn(), end: (text: string) => stdinWrites.push(text) } + } } - }) + ) } function failWith(error: string): void { - execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: Function) => { - cb(null, JSON.stringify({ success: false, error }), '') - }) + execFileMock.mockImplementation( + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { + cb(null, JSON.stringify({ success: false, error }), '') + } + ) } class TestEvent { @@ -352,7 +360,7 @@ describe('AgentBrowserBridge', () => { try { const closeKill = vi.fn() execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { return { kill: closeKill } } @@ -445,7 +453,7 @@ describe('AgentBrowserBridge', () => { let releaseSnapshot: (() => void) | null = null const activeChild = { kill: vi.fn() } execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('snapshot')) { releaseSnapshot = () => { cb(null, JSON.stringify({ success: false, error: CDP_DISCOVERY_FAILURE }), '') @@ -486,7 +494,7 @@ describe('AgentBrowserBridge', () => { it('handles malformed JSON from agent-browser', async () => { execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, 'not json at all', '') } ) @@ -748,7 +756,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -782,7 +790,7 @@ describe('AgentBrowserBridge', () => { let releaseSnapshot: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -877,7 +885,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -1024,7 +1032,7 @@ describe('AgentBrowserBridge', () => { let releaseFirstScreenshot: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -1103,7 +1111,7 @@ describe('AgentBrowserBridge', () => { webContentsFromIdMock.mockReturnValue(wc) execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, JSON.stringify({ success: true, data: null }), '') } ) @@ -1137,7 +1145,7 @@ describe('AgentBrowserBridge', () => { const killedError = Object.assign(new Error('timeout'), { killed: true }) execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -1167,7 +1175,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] let releaseDestroyClose: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) if (args.includes('close')) { if (!releaseDestroyClose) { @@ -1212,7 +1220,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] let releaseStaleClose: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) if (args.includes('close') && !releaseStaleClose) { releaseStaleClose = () => { @@ -1270,7 +1278,7 @@ describe('AgentBrowserBridge', () => { } execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('snapshot')) { resolveRunningCommand = () => cb(killedError, '', '') return activeChild @@ -1354,7 +1362,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -1390,7 +1398,7 @@ describe('AgentBrowserBridge', () => { const commandCalls: string[][] = [] execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { commandCalls.push(args) cb(null, JSON.stringify({ success: true, data: { ok: true } }), '') } @@ -1577,7 +1585,7 @@ describe('AgentBrowserBridge', () => { let releaseSnapshot: (() => void) | null = null execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -1875,7 +1883,7 @@ describe('AgentBrowserBridge', () => { let helperSessionIsStale = false execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') } else if (args.includes('snapshot')) { @@ -2549,7 +2557,7 @@ describe('AgentBrowserBridge', () => { it('returns browser_timeout for timed conditional waits without recycling the session', async () => { const killedError = Object.assign(new Error('timeout'), { killed: true }) execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('wait')) { cb(killedError, '', '') return @@ -2573,7 +2581,7 @@ describe('AgentBrowserBridge', () => { it('passes stderr through as error message on execFile failure', async () => { execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -2586,7 +2594,7 @@ describe('AgentBrowserBridge', () => { it('falls back to error.message when stderr is empty', async () => { execFileMock.mockImplementation( - (_bin: string, args: string[], _opts: unknown, cb: Function) => { + (_bin: string, args: string[], _opts: unknown, cb: ExecFileCallback) => { if (args.includes('close')) { cb(null, JSON.stringify({ success: true, data: null }), '') return @@ -2601,7 +2609,7 @@ describe('AgentBrowserBridge', () => { it('returns browser_error with truncated output for malformed JSON', async () => { execFileMock.mockImplementation( - (_bin: string, _args: string[], _opts: unknown, cb: Function) => { + (_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => { cb(null, 'Error: not json output', '') } ) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index e7703a1f5e5..22b891cd2a0 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -596,7 +596,7 @@ async function importValidatedCookies( } } diag( - ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${err}` + ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` ) } } @@ -1019,7 +1019,7 @@ function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult return { key: Buffer.from(result, 'base64'), mode: 'aes-256-gcm' } } catch (err) { - diag(` Windows DPAPI key extraction failed: ${err}`) + diag(` Windows DPAPI key extraction failed: ${String(err)}`) return null } } @@ -1339,7 +1339,7 @@ async function importCookiesFromFirefox( return importValidatedCookies(validated, rows.length, targetPartition) } catch (err) { rmSync(tmpDir, { recursive: true, force: true }) - diag(` Firefox import failed: ${err}`) + diag(` Firefox import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Firefox. Try closing Firefox first.' @@ -1361,7 +1361,7 @@ async function importCookiesFromSafari( try { data = readFileSync(browser.cookiesPath) } catch (err) { - diag(` Safari read failed: ${err}`) + diag(` Safari read failed: ${String(err)}`) // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. const isPermError = err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' @@ -1392,7 +1392,7 @@ async function importCookiesFromSafari( return importValidatedCookies(valid, cookies.length, targetPartition) } catch (err) { - diag(` Safari import failed: ${err}`) + diag(` Safari import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Safari.' } } } @@ -1480,7 +1480,7 @@ export async function importCookiesFromBrowser( } catch { /* best-effort */ } - diag(` Chromium snapshot failed: ${err}`) + diag(` Chromium snapshot failed: ${String(err)}`) return { ok: false, reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` @@ -1527,7 +1527,7 @@ export async function importCookiesFromBrowser( placeholders = targetCols.map(() => '?').join(', ') stagingDb.exec('DELETE FROM cookies') } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${err}`) + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) stagingAvailable = false targetColumnInfo = null colList = null @@ -1807,7 +1807,7 @@ export async function importCookiesFromBrowser( } catch { /* may not exist yet */ } - diag(` SQLite import failed: ${err}`) + diag(` SQLite import failed: ${String(err)}`) return { ok: false, reason: reasonWithDiagLog( @@ -1818,7 +1818,7 @@ export async function importCookiesFromBrowser( try { sourceSnapshot.cleanup() } catch (err) { - diag(` Chromium snapshot cleanup failed: ${err}`) + diag(` Chromium snapshot cleanup failed: ${String(err)}`) } } } diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index bd0e5698cd8..c6dfab8873d 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,14 +43,17 @@ import { buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' import { cleanElectronUserAgent } from './browser-session-ua' -import type { BrowserViewportOverride } from '../../shared/types' +import type { + BrowserViewportOverride, + BrowserCertificateFailure, + BrowserLoadError +} from '../../shared/types' import { type BrowserAnnotationViewportBridgeOptions, BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' -import type { BrowserCertificateFailure, BrowserLoadError } from '../../shared/types' import { BrowserCertificateTrustController, type ManagedBrowserGuestContext diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index daaae60540e..b3b1fae9401 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -250,7 +250,7 @@ async function sendRequest( let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk const newlineIndex = buffer.indexOf('\n') if (newlineIndex === -1) { diff --git a/src/main/claude-accounts/keychain.ts b/src/main/claude-accounts/keychain.ts index 910e1786a7b..49d89c33d95 100644 --- a/src/main/claude-accounts/keychain.ts +++ b/src/main/claude-accounts/keychain.ts @@ -170,9 +170,9 @@ function isKeychainNotFoundError(error: unknown): boolean { : undefined const message = error && typeof error === 'object' - ? `${(error as { stderr?: unknown }).stderr ?? ''} ${ + ? `${String((error as { stderr?: unknown }).stderr ?? '')} ${String( (error as { message?: unknown }).message ?? '' - }`.toLowerCase() + )}`.toLowerCase() : String(error).toLowerCase() return code === 44 || message.includes('could not be found') || message.includes('not be found') } diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 18d2fcc8f18..9c120c230bc 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -654,7 +654,7 @@ export class CliInstaller { private isWindowsPackagedBundledCommand( commandPath: string | null, launcherPath: string | null - ): commandPath is string { + ): boolean { return ( this.platform === 'win32' && this.isPackaged && diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index 9e28dbbdc2a..7814994c758 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -8,12 +8,12 @@ import { rmSync, symlinkSync, statSync, - writeFileSync + writeFileSync, + existsSync } from 'node:fs' import { tmpdir } from 'node:os' import type * as Os from 'node:os' import { join } from 'node:path' -import { existsSync } from 'node:fs' import { wrapPosixHookCommand } from '../agent-hooks/installer-utils' import { computeTrustKey, diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 8ca0f39bdc1..d0d748a9bfa 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -2,9 +2,15 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { combineUnsubscribes } from './combine-unsubscribes' import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' -import type { IPtyProvider, PtyBackgroundStreamEvent } from '../providers/types' -import type { PtyDataEvent, PtyProviderBufferSnapshot } from '../providers/types' -import type { PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { + IPtyProvider, + PtyBackgroundStreamEvent, + PtyDataEvent, + PtyProviderBufferSnapshot, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly routesFreshSpawnsToLocalProvider = true diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index fb574dd64da..126df0593de 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -340,7 +340,7 @@ export class HistoryManager { private async waitForSessionMutations(sessionId: string): Promise { while (this.pendingSessionMutations.has(sessionId)) { - await Promise.allSettled(this.pendingSessionMutations.get(sessionId) ?? []) + await Promise.allSettled(this.pendingSessionMutations.get(sessionId)!) } } } diff --git a/src/main/github/pr-head-tracking-ref.ts b/src/main/github/pr-head-tracking-ref.ts index 84788b6c056..9ffa7374225 100644 --- a/src/main/github/pr-head-tracking-ref.ts +++ b/src/main/github/pr-head-tracking-ref.ts @@ -44,7 +44,7 @@ export async function fetchGitHubPullRequestHeadRef( options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { if (!isValidReviewHeadNumber(prNumber)) { - throw new Error(`Invalid pull request number: ${prNumber}`) + throw new Error(`Invalid pull request number: ${String(prNumber)}`) } if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Pull request fetch remote must not start with "-".') diff --git a/src/main/gitlab/mr-head-tracking-ref.ts b/src/main/gitlab/mr-head-tracking-ref.ts index fa3540e1257..e68a8235a9a 100644 --- a/src/main/gitlab/mr-head-tracking-ref.ts +++ b/src/main/gitlab/mr-head-tracking-ref.ts @@ -25,7 +25,7 @@ export async function fetchGitLabMergeRequestHeadRef( options: { localGitExecOptions?: LocalGitExecOptions } = {} ): Promise { if (!isValidReviewHeadNumber(mrIid)) { - throw new Error(`Invalid merge request iid: ${mrIid}`) + throw new Error(`Invalid merge request iid: ${String(mrIid)}`) } if (!isSafeReviewHeadFetchRemote(remote)) { throw new Error('Merge request fetch remote must not start with "-".') diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index 0b37206d6bb..066e539b815 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -10,6 +10,7 @@ import type { GitLabPipelineJob, GitLabWorkItem, GitLabWorkItemDetails, + IssueSourcePreference, MRComment } from '../../shared/types' import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' @@ -24,7 +25,6 @@ import { type LocalGitExecOptions, type ProjectRef } from './gl-utils' -import type { IssueSourcePreference } from '../../shared/types' function encodedProject(projectPath: string): string { return encodeURIComponent(projectPath) diff --git a/src/main/index.ts b/src/main/index.ts index a76f6f50d94..ceb1bf61807 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,7 +20,15 @@ import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { killAllPty } from './ipc/pty' +import { + killAllPty, + clearProviderPtyState, + getPtyIdForPaneKey, + registerPaneKeyTeardownListener, + getLocalPtyProvider, + getSshPtyProvider, + registerHeadlessPtyRuntime +} from './ipc/pty' import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init' import { closeAllWatchers } from './ipc/filesystem-watcher' import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' @@ -203,14 +211,6 @@ import { setDefaultWslDistroOverride } from './git/runner' import { getRepoIdFromWorktreeId } from '../shared/worktree-id' import { parseWorkspaceKey } from '../shared/workspace-scope' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime -} from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' import { browserCertificateTrustController, browserManager } from './browser/browser-manager' diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index ebfd52e63a7..0e44962906e 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -121,21 +121,18 @@ async function scanAiVaultSessionsByHostScope( if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] - return mergeAiVaultListResults( - await Promise.all([ - scanLocalAiVaultSessions(args), - ...getActiveSshAiVaultHostInfos().map((hostInfo) => - scanSshAiVaultSessions(hostInfo.targetId, args) - ), - ...runtimeHosts.hostInfos.map((hostInfo) => - scanRuntimeAiVaultSessions(hostInfo, args, { - timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS - }) - ), - ...runtimeResults - ]), - args?.limit - ) + const scannedResults = await Promise.all([ + scanLocalAiVaultSessions(args), + ...getActiveSshAiVaultHostInfos().map((hostInfo) => + scanSshAiVaultSessions(hostInfo.targetId, args) + ), + ...runtimeHosts.hostInfos.map((hostInfo) => + scanRuntimeAiVaultSessions(hostInfo, args, { + timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS + }) + ) + ]) + return mergeAiVaultListResults([...scannedResults, ...runtimeResults], args?.limit) } const parsed = parseExecutionHostId(executionHostScope) diff --git a/src/main/ipc/diagnostics.ts b/src/main/ipc/diagnostics.ts index 53d8d2f6ef3..5446f2aca4f 100644 --- a/src/main/ipc/diagnostics.ts +++ b/src/main/ipc/diagnostics.ts @@ -20,8 +20,7 @@ import { app, dialog, ipcMain, shell } from 'electron' import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' -import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' -import { tmpdir } from 'node:os' +import { arch as osArch, platform as osPlatform, release as osRelease, tmpdir } from 'node:os' import { join } from 'node:path' import { collectDiagnosticBundle, diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index f75c9c77d39..593607d4a20 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -2,11 +2,13 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' import { getEphemeralVmRecipeResultConnection, - getEphemeralVmRecipeResultWarnings, - redactEphemeralVmRecipeDiagnosticText, - type EphemeralVmRecipeResultWarning, type EphemeralVmRecipeDoctorResult } from '../../shared/ephemeral-vm-recipes' +import { + getEphemeralVmRecipeResultWarnings, + redactEphemeralVmRecipeDiagnosticText, + type EphemeralVmRecipeResultWarning +} from '../../shared/ephemeral-vm-recipe-diagnostics' // Why: import directly from the doctor module (not the barrel) — it uses Node // fs/path and must stay out of the browser bundle that imports the barrel. import { doctorEphemeralVmRecipe } from '../../shared/ephemeral-vm-recipe-doctor' diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index a01225bfccf..6fcc53bab71 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -13,7 +13,8 @@ import type { GitHubPRRefreshCandidate, GitHubPRRefreshEnqueueResult, GitHubPRRefreshReason, - PRRefreshOutcome + PRRefreshOutcome, + GitHubPRFile } from '../../shared/types' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { TaskSourceContext } from '../../shared/task-source-context' @@ -68,7 +69,6 @@ import { type PRRefreshValidationDenialReason } from '../github/pr-refresh-validation-backoff' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' import { getProjectViewTable, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2e706452fbb..58b841508bd 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -205,7 +205,10 @@ vi.mock('../codex/codex-pane-account-registry', () => ({ recordCodexPaneAccount: recordCodexPaneAccountMock, forgetCodexPaneAccount: forgetCodexPaneAccountMock })) -import { LocalPtyProvider } from '../providers/local-pty-provider' +import { + LocalPtyProvider, + _resetLocalPtyProviderStateForTest +} from '../providers/local-pty-provider' import { makePaneKey } from '../../shared/stable-pane-id' import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { @@ -228,7 +231,6 @@ import { restorePtyIncarnation, type PrepareCodexSessionResume } from './pty' -import { _resetLocalPtyProviderStateForTest } from '../providers/local-pty-provider' import { resetMacosLoginShellPreflightForTests } from '../providers/macos-tcc-login-shell' import { _resetHiddenRendererPtyDeliveryGateForTest, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 51cc0d75d12..ef903dcae05 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -34,7 +34,11 @@ import { registerDashboardPopoutHandlers } from './dashboard-popout' import { registerTerminalPreviewHandlers } from './terminal-preview' import { registerDeveloperPermissionHandlers } from './developer-permissions' import { registerComputerUsePermissionHandlers } from './computer-use-permissions' -import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' +import { + setTrustedBrowserRendererWebContentsId, + setAgentBrowserBridgeRef, + registerBrowserHandlers +} from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' import { registerDiagnosticsHandlers } from './diagnostics' @@ -45,7 +49,6 @@ import { registerLocalhostWorktreeLabelHandlers } from './localhost-worktree-lab import { registerAutomationHandlers } from './automations' import { registerKeybindingHandlers } from './keybindings' import { registerTelemetryHandlers } from './telemetry' -import { registerBrowserHandlers } from './browser' import { registerShellHandlers } from './shell' import { registerPetHandlers } from './pet' import { registerPluginHandlers } from './plugins' diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 964c877a557..f42c64a803d 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,5 +1,5 @@ import { ipcMain } from 'electron' -import type { SshConnectionManager } from '../ssh/ssh-connection' +import type { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshExecOptions } from '../ssh/ssh-connection-utils' import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 3780b827ef4..f40370572c8 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -106,7 +106,7 @@ vi.mock('../ssh/ssh-connection-store', () => ({ } })) -vi.mock('../ssh/ssh-connection', () => ({ +vi.mock('../ssh/ssh-connection-manager', () => ({ SshConnectionManager: class MockSshConnectionManager { constructor(callbacks: unknown) { const manager = (mockNextConnectionManagers.shift() ?? diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 287507985e4..846536fdd3b 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -3,7 +3,8 @@ import { ipcMain, powerMonitor, type BrowserWindow } from 'electron' import { appendFileSync } from 'node:fs' import type { Store } from '../persistence' import { SshConnectionStore } from '../ssh/ssh-connection-store' -import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection' +import type { SshConnectionCallbacks } from '../ssh/ssh-connection' +import { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index 78f47fbbd41..02f1b9e16be 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -17,8 +17,7 @@ import { getOnboardingCohortAtEmit } from '../telemetry/onboarding-cohort-classi import { resolveConsent, type ConsentState } from '../telemetry/consent' import type { Store } from '../persistence' import { isCohortExtendedEvent, isOnboardingEvent } from '../../shared/telemetry-events' -import type { EventName, EventProps } from '../../shared/telemetry-events' -import type { OptInVia } from '../../shared/telemetry-events' +import type { EventName, EventProps, OptInVia } from '../../shared/telemetry-events' // Module-level store ref: handlers need a synchronous `settings.telemetry` read to derive `via` before any mutation. let storeRef: Store | null = null diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index b86e5c0ec42..c10533acb7a 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -28,7 +28,7 @@ import { resolveDefaultBaseRefViaExec, resolveDefaultBaseRefWithLocalGit } from '../git/repo' -import { resolveLocalGitUsername } from '../git/git-username' +import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' @@ -38,8 +38,11 @@ import { validateGitPushTarget } from '../git/push-target-validation' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { gitExecFileAsync } from '../git/runner' import { parseGitHubOwnerRepo } from '../github/gh-utils' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime' +import type { + OrcaRuntimeService, + RemoteFetchResult, + RemoteTrackingBase +} from '../runtime/orca-runtime' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { buildPosixRunnerScript, @@ -58,7 +61,6 @@ import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import type { SshGitProvider } from '../providers/ssh-git-provider' import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { getSshGitUsername } from '../git/git-username' import { runWorktreeChangeInvalidators } from './worktree-change-invalidators' import { registerOptionalSshWorktreeCreateRoots, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 593ba050b0b..949167f5661 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -94,7 +94,16 @@ import { type SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' -import { getRepoExecutionHostId, parseExecutionHostId } from '../shared/execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostOrder, + normalizeExecutionHostId, + normalizeVisibleExecutionHostIds, + toSshExecutionHostId, + type ExecutionHostId +} from '../shared/execution-host' import { getDefaultPersistedState, getDefaultNotificationSettings, @@ -116,14 +125,6 @@ import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode' import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides' -import { - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostOrder, - normalizeExecutionHostId, - normalizeVisibleExecutionHostIds, - toSshExecutionHostId, - type ExecutionHostId -} from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' import { migrateUiHostScopeSshTargetId, diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index c3901090c33..40ad6978fcb 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -60,11 +60,10 @@ export class PluginContentPackRegistry { while (true) { const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean => approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) - await Promise.all([ - this.languagePacks.reconcile(discovered, approveAtomically), - this.vmRecipes.reconcile(discovered, approveAtomically), - this.commands.reconcile(discovered, approveAtomically, keybindings) - ]) + const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically) + const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically) + this.commands.reconcile(discovered, approveAtomically, keybindings) + await Promise.all([languagePacks, vmRecipes]) let foundNewError = false for (const pluginKey of approvedKeys) { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index e31110525fa..5e29ee31eb7 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */ -import { basename, delimiter } from 'node:path' +import { basename, delimiter, win32 as pathWin32 } from 'node:path' import { randomUUID } from 'node:crypto' -import { win32 as pathWin32 } from 'node:path' import { resolveWindowsShellLaunchArgs } from './windows-shell-args' import { resolveEffectiveWindowsPowerShell, diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 5dc32a48ed3..9974669fbcd 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -1009,7 +1009,7 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledTimes(1) pendingDiff.resolve() - await expect(Promise.all(reads)).resolves.toEqual(Array(8).fill(diff)) + await expect(Promise.all(reads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) mux.request.mockReset() const branchDiffs = [diff] @@ -1026,7 +1026,9 @@ describe('SshGitProvider', () => { await waitForRequestCount(mux.request, 1) expect(mux.request).toHaveBeenCalledTimes(1) pendingBranchDiff.resolve() - await expect(Promise.all(branchReads)).resolves.toEqual(Array(8).fill(branchDiffs)) + await expect(Promise.all(branchReads)).resolves.toEqual( + Array.from({ length: 8 }, () => branchDiffs) + ) mux.request.mockReset() const pendingCommitDiff = deferredValue(diff) @@ -1043,7 +1045,7 @@ describe('SshGitProvider', () => { await waitForRequestCount(mux.request, 1) expect(mux.request).toHaveBeenCalledTimes(1) pendingCommitDiff.resolve() - await expect(Promise.all(commitReads)).resolves.toEqual(Array(8).fill(diff)) + await expect(Promise.all(commitReads)).resolves.toEqual(Array.from({ length: 8 }, () => diff)) }) it('retries diff RPCs after an in-flight rejection settles', async () => { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index 206748e1bc5..79ce437b2d6 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -20,14 +20,14 @@ import { readActiveClaudeKeychainCredentials, readActiveClaudeKeychainCredentialsStrict, readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials + writeActiveClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import { readClaudeManagedAuthFile, resolveOwnedClaudeManagedAuthPath, writeClaudeManagedAuthFile } from '../claude-accounts/managed-auth-path' -import { writeManagedClaudeKeychainCredentials } from '../claude-accounts/keychain' import { isOauthTokenExpiring, refreshClaudeOauthCredentials diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index e37c7106747..adbae1adf7e 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1286,7 +1286,7 @@ export class RuntimeFileCommands { callback: (events: FsChangeEvent[]) => void, onTerminalError: (error: Error) => void = () => undefined, signal?: AbortSignal - ): Promise<() => void> { + ): Promise<() => Promise> { const target = await this.resolveFileExplorerPath(worktreeSelector, '') const open = async (): Promise<{ unsubscribe: () => Promise diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f06a01d20f9..dac1b9f0ffd 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -184,7 +184,20 @@ import type { TuiAgent, WorkspaceCreateTelemetrySource, WorkspaceSessionState, - DirEntry + DirEntry, + GitHubIssueUpdate, + GitHubPullRequestStateUpdate, + GitHubPRFile, + GitHubPRReviewCommentInput, + GitLabIssueUpdate, + GitLabMRInlineCommentInput, + GitLabProjectRef, + GitLabWorkItem, + ListWorkItemsResult, + MRListState, + PRRefreshOutcome, + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { @@ -237,7 +250,65 @@ import type { } from '../../shared/linear-agent-access' import { HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDesktopWindowStatus + type RuntimeDesktopWindowStatus, + type RuntimeGraphStatus, + type RuntimeRepoSearchRefs, + type RuntimeTerminalRead, + type RuntimeTerminalRename, + type RuntimeTerminalAgentStatus, + type RuntimeTerminalSend, + type RuntimeTerminalCreate, + type RuntimeTerminalPresentation, + type RuntimeTerminalSplit, + type RuntimeTerminalFocus, + type RuntimeTerminalClose, + type RuntimeTerminalListResult, + type RuntimeTerminalOrphanAdoptionRequest, + type RuntimeTerminalOrphanAdoptionResult, + type RuntimeWorktreeTerminalSleepResult, + type RuntimeTerminalResolvePane, + type RuntimeTerminalState, + type RuntimeStatus, + type RuntimeSyncWindowGraphResult, + type RuntimeTerminalWait, + type RuntimeTerminalWaitBlockedReason, + type RuntimeTerminalWaitCondition, + type RuntimeWorktreePsSummary, + type RuntimeWorktreeAgentRow, + type RuntimeWorktreeStatus, + type RuntimeSpeechModelSummary, + type RuntimeSpeechSetupState, + type RuntimeTerminalShow, + type RuntimeTerminalSummary, + type RuntimeTerminalVisualGroupNode, + type RuntimeTerminalVisualLayout, + type RuntimeTerminalVisualLayoutNode, + type RuntimeTerminalVisualPaneNode, + type RuntimeTerminalVisualTab, + type RuntimeSyncedLeaf, + type RuntimeSyncedTab, + type RuntimeMarkdownReadTabResult, + type RuntimeMarkdownSaveTabResult, + type RuntimeMobileSessionCreateTerminalResult, + type RuntimeMobileSessionClientTab, + type RuntimeMobileSessionTabCloseResult, + type RuntimeMobileSessionMarkdownTab, + type RuntimeMobileSessionTabMove, + type RuntimeMobileSessionTabMoveResult, + type RuntimeMobileSessionTabGroup, + type RuntimeMobileSessionSnapshotTab, + type RuntimeMobileSessionTerminalTab, + type RuntimeMobileSessionBrowserTab, + type RuntimeMobileSessionTabsRemovedResult, + type RuntimeMobileSessionTabsResult, + type RuntimeMobileSessionTabsSnapshot, + type RuntimeSessionTabCloseReason, + type RuntimeBrowserDriverState, + type RuntimeTerminalDriverState, + type RuntimeSyncWindowGraph, + type RuntimeWorktreeListResult, + type BrowserTabInfo, + type BrowserScreencastResult } from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, @@ -377,66 +448,6 @@ import { scanWorkspacePortProbes } from '../ports/workspace-port-ownership' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import type { - RuntimeGraphStatus, - RuntimeRepoSearchRefs, - RuntimeTerminalRead, - RuntimeTerminalRename, - RuntimeTerminalAgentStatus, - RuntimeTerminalSend, - RuntimeTerminalCreate, - RuntimeTerminalPresentation, - RuntimeTerminalSplit, - RuntimeTerminalFocus, - RuntimeTerminalClose, - RuntimeTerminalListResult, - RuntimeTerminalOrphanAdoptionRequest, - RuntimeTerminalOrphanAdoptionResult, - RuntimeWorktreeTerminalSleepResult, - RuntimeTerminalResolvePane, - RuntimeTerminalState, - RuntimeStatus, - RuntimeSyncWindowGraphResult, - RuntimeTerminalWait, - RuntimeTerminalWaitBlockedReason, - RuntimeTerminalWaitCondition, - RuntimeWorktreePsSummary, - RuntimeWorktreeAgentRow, - RuntimeWorktreeStatus, - RuntimeSpeechModelSummary, - RuntimeSpeechSetupState, - RuntimeTerminalShow, - RuntimeTerminalSummary, - RuntimeTerminalVisualGroupNode, - RuntimeTerminalVisualLayout, - RuntimeTerminalVisualLayoutNode, - RuntimeTerminalVisualPaneNode, - RuntimeTerminalVisualTab, - RuntimeSyncedLeaf, - RuntimeSyncedTab, - RuntimeMarkdownReadTabResult, - RuntimeMarkdownSaveTabResult, - RuntimeMobileSessionCreateTerminalResult, - RuntimeMobileSessionClientTab, - RuntimeMobileSessionTabCloseResult, - RuntimeMobileSessionMarkdownTab, - RuntimeMobileSessionTabMove, - RuntimeMobileSessionTabMoveResult, - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionSnapshotTab, - RuntimeMobileSessionTerminalTab, - RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionTabsRemovedResult, - RuntimeMobileSessionTabsResult, - RuntimeMobileSessionTabsSnapshot, - RuntimeSessionTabCloseReason, - RuntimeBrowserDriverState, - RuntimeTerminalDriverState, - RuntimeSyncWindowGraph, - RuntimeWorktreeListResult, - BrowserTabInfo, - BrowserScreencastResult -} from '../../shared/runtime-types' import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' @@ -474,7 +485,13 @@ import { deriveClientSessionTabSelection, projectClientSessionTabSelection } from './client-session-tab-selection' -import type { PtyProviderBufferSnapshot } from '../providers/types' +import type { + PtyProviderBufferSnapshot, + IFilesystemProvider, + IPtyProvider, + PtyProcessInfo, + PtyTransientFact +} from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { AgentTeamsTmuxCompatRequest, @@ -527,9 +544,9 @@ import { addPRReviewCommentReply, listLabels, listAssignableUsers, - type MainWorkItem + type MainWorkItem, + type GitHubPRBranchLookupOptions } from '../github/client' -import type { GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { fetchGitHubPullRequestHeadRef, @@ -579,19 +596,6 @@ import { type GitLabIssueListState } from '../gitlab/gitlab-preload-args' import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' -import type { - GitHubIssueUpdate, - GitHubPullRequestStateUpdate, - GitHubPRFile, - GitHubPRReviewCommentInput, - GitLabIssueUpdate, - GitLabMRInlineCommentInput, - GitLabProjectRef, - GitLabWorkItem, - ListWorkItemsResult, - MRListState, - PRRefreshOutcome -} from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { CreateHostedReviewInput, @@ -785,7 +789,7 @@ import { removeWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' -import { isENOENT } from '../ipc/filesystem-auth' +import { isENOENT, invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getDefaultTabCommandTrustContent, @@ -862,7 +866,6 @@ import { UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { closeLocalWatcherForWorktreePath, @@ -894,12 +897,6 @@ import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' -import type { - IFilesystemProvider, - IPtyProvider, - PtyProcessInfo, - PtyTransientFact -} from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -922,7 +919,6 @@ import type { } from '../codex-accounts/service' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { RateLimitService } from '../rate-limits/service' -import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types' import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope' diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 2af865a0df5..7fcd5288f5d 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -367,7 +367,7 @@ export class Coordinator { terminals.push(created.handle) this.opts.onLog(`Created worker terminal ${created.handle}`) } catch (err) { - this.opts.onLog(`Failed to create terminal: ${err}`) + this.opts.onLog(`Failed to create terminal: ${String(err)}`) return } } @@ -383,7 +383,7 @@ export class Coordinator { try { await this.dispatchTask(task, targetHandle) } catch (err) { - this.opts.onLog(`Failed to dispatch task ${task.id}: ${err}`) + this.opts.onLog(`Failed to dispatch task ${task.id}: ${String(err)}`) } } } diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index b3aeb044daf..f234dce1536 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -6,8 +6,10 @@ import { RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION } from '../../../../shared/protocol-version' -import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../../shared/agent-session-host-authority' -import { AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../../shared/agent-session-host-authority' +import { + AGENT_SESSION_RPC_ERROR_CODES, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../../../shared/agent-session-host-authority' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index cd6c9f00cca..e494148ef7f 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -3,8 +3,7 @@ import { defineMethod, type RpcMethod } from '../core' import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { parseExecutionHostId } from '../../../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. // Each scopePath is a host-local match prefix (validated/capped, never used for diff --git a/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts b/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts index f612f27dc0d..daf01b27ef7 100644 --- a/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts +++ b/src/main/runtime/rpc/methods/file-watch-stream-lifecycle.ts @@ -17,9 +17,9 @@ export async function runFileWatchStream(args: { let settled = false let setupFailed = false let watchReady = false - let unwatch: (() => void) | null = null + let unwatch: (() => Promise) | null = null let terminalError: Error | null = null - let setupPromise: Promise<() => void> | null = null + let setupPromise: Promise<() => Promise> | null = null let cleanupPromise: Promise | null = null let logicalCleanupStarted = false let endEmitted = false diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 51758dbc803..1e94b95176e 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,6 +1,9 @@ import { z } from 'zod' -import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' -import type { AgentType } from '../../../../shared/native-chat-types' +import type { + NativeChatBlock, + NativeChatMessage, + AgentType +} from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 87d984b6f02..308c8ae2010 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -50,7 +50,7 @@ async function sendRequest( let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk const newlineIndex = buffer.indexOf('\n') if (newlineIndex === -1) { @@ -4061,7 +4061,7 @@ describe('OrcaRuntimeRpcServer', () => { let buffer = '' socket.setEncoding('utf8') socket.once('error', reject) - socket.on('data', (chunk) => { + socket.on('data', (chunk: string) => { buffer += chunk const newlineIndex = buffer.indexOf('\n') if (newlineIndex === -1) { diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index c05b6c33bfe..0412c97994d 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -301,7 +301,7 @@ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { const text = payload.toString('utf-8') const msg = JSON.parse(text) as JsonRpcMessage if (msg.jsonrpc !== '2.0') { - throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) + throw new Error(`Invalid JSON-RPC version: ${String((msg as Record).jsonrpc)}`) } return msg } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 1ab923bee60..3f4d4fbe0ba 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -154,10 +154,10 @@ vi.mock('./ssh-config-parser', () => ({ import { SshConnection, - SshConnectionManager, shouldUseSystemSshTransport, type SshConnectionCallbacks } from './ssh-connection' +import { SshConnectionManager } from './ssh-connection-manager' import { resolveWithSshG, type SshResolvedConfig } from './ssh-config-parser' import { downloadFileViaSystemSsh, diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 899fa5b35f3..224f888eadb 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -1371,5 +1371,3 @@ export function shouldUseSystemSshTransport( resolved?.proxyJump != null ) } - -export { SshConnectionManager } from './ssh-connection-manager' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index d60947df694..245b0d3a1b7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -32,7 +32,15 @@ import type { } from '../shared/terminal-render-desync-evidence' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { SshMutationExpectation } from '../shared/ssh-types' +import type { + SshMutationExpectation, + SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, + SshTarget, + PortForwardEntry, + EnrichedDetectedPort +} from '../shared/ssh-types' import type { CreateLocalOrcaProfileArgs, CreateLocalOrcaProfileResult, @@ -260,10 +268,8 @@ import type { import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' -import type { - EphemeralVmRecipeDoctorResult, - EphemeralVmRecipeResultWarning -} from '../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeDoctorResult } from '../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeResultWarning } from '../shared/ephemeral-vm-recipe-diagnostics' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' import type { RuntimeAccessGrant } from '../shared/runtime-access-grants' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' @@ -431,14 +437,6 @@ import type { WorkspacePortScanResult } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { - SshConnectionState, - SshConfigImportResult, - SshTargetAddResult, - SshTarget, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, diff --git a/src/preload/index.ts b/src/preload/index.ts index bb0a17220ed..3023f7382e9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,7 +21,15 @@ import type { } from '../shared/agent-session-resume' import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' -import type { SshMutationExpectation } from '../shared/ssh-types' +import type { + SshMutationExpectation, + SshConnectionState, + SshConfigImportResult, + SshTargetAddResult, + SshTarget, + PortForwardEntry, + EnrichedDetectedPort +} from '../shared/ssh-types' import type { PluginPanelActionOutcome, PluginPanelEntry @@ -158,14 +166,6 @@ import { richMarkdownContextMenuCommandChannel, type RichMarkdownContextMenuCommandPayload } from '../shared/rich-markdown-context-menu' -import type { - SshConnectionState, - SshConfigImportResult, - SshTargetAddResult, - SshTarget, - PortForwardEntry, - EnrichedDetectedPort -} from '../shared/ssh-types' import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload, @@ -182,7 +182,18 @@ import type { SpeechTranscriptEvent } from '../shared/speech-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' -import type { PreflightRuntimeContext, RefreshAgentsResult } from './api-types' +import type { + PreflightRuntimeContext, + RefreshAgentsResult, + NativeChatAppendedPayload, + NativeChatReadSessionResult, + NativeChatSubscriptionFrame, + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine, + PreloadApi +} from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' @@ -205,11 +216,6 @@ import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybi import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation' import type { AgentType } from '../shared/native-chat-types' -import type { - NativeChatAppendedPayload, - NativeChatReadSessionResult, - NativeChatSubscriptionFrame -} from './api-types' import { ORCA_APP_RESTART_ABORTED_EVENT, ORCA_APP_RESTART_STARTED_EVENT, @@ -252,13 +258,6 @@ import type { } from '../shared/crash-reporting' import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import type { - PluginHostInstallResult, - PluginHostInstallSource, - PluginHostListEntry, - PluginHostLogLine, - PreloadApi -} from './api-types' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 4df0f7bd9b5..617ab84e679 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -286,7 +286,7 @@ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { const text = payload.toString('utf-8') const msg = JSON.parse(text) as JsonRpcMessage if (msg.jsonrpc !== '2.0') { - throw new Error(`Invalid JSON-RPC version: ${(msg as Record).jsonrpc}`) + throw new Error(`Invalid JSON-RPC version: ${String((msg as Record).jsonrpc)}`) } return msg } diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 267b9ee8b36..82110b58db2 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -342,7 +342,7 @@ async function main(): Promise { }) process.on('unhandledRejection', (reason) => { - relayLogLine(`[relay] Unhandled rejection: ${reason}`) + relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) }) // Why: guards writes after the stdin/SSH channel drops so keepalive/pty.data frames don't hit a dead pipe (EPIPE). diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index ddb11435c4d..35c7e2184ab 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React from 'react' -import { act } from 'react' +import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 0fdcc3459ef..4e89e8b0e44 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -33,6 +33,10 @@ import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' import AgentCombobox from '@/components/agent/AgentCombobox' import { getAgentCatalog } from '@/lib/agent-catalog' +import { + DEFAULT_DISABLED_TUI_AGENTS, + filterEnabledTuiAgents +} from '../../../shared/tui-agent-selection' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' @@ -44,7 +48,6 @@ import { } from '@/lib/text-control-paste' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' -import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import type { GitHubWorkItem, GitLabWorkItem, @@ -955,7 +958,9 @@ export default function NewWorkspaceComposerCard({ const openModal = useAppStore((s) => s.openModal) const activeModal = useAppStore((s) => s.activeModal) const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) - const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) + const disabledTuiAgents = useAppStore( + (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS + ) const updateSettings = useAppStore((s) => s.updateSettings) const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index d14db9fcb0e..4a1d74e6308 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -44,7 +44,8 @@ import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local- import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { getSettingsFocusedExecutionHostId, - parseExecutionHostId + parseExecutionHostId, + getRepoExecutionHostId } from '../../../shared/execution-host' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' @@ -179,7 +180,6 @@ import { readLinearBoardIssueDragData, writeLinearBoardIssueDragData } from '@/lib/linear-board-drag-payload' -import { getRepoExecutionHostId } from '../../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { diff --git a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx index 4865021f0a4..ac551fbc111 100644 --- a/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx +++ b/src/renderer/src/components/agent-session-continuation/AgentSessionContinuationDialog.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React, { type ReactNode } from 'react' -import { act } from 'react' +import React, { type ReactNode, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' diff --git a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx index ee15205457b..ef83b076201 100644 --- a/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx +++ b/src/renderer/src/components/dictation/use-hold-dictation-gesture.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { createRef, type MutableRefObject } from 'react' -import { act } from 'react' +import { createRef, type MutableRefObject, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DictationState } from '../../../../shared/speech-types' diff --git a/src/renderer/src/components/editor/ChangesModeView.test.tsx b/src/renderer/src/components/editor/ChangesModeView.test.tsx index d8b41e4b31d..ae8d13f5a34 100644 --- a/src/renderer/src/components/editor/ChangesModeView.test.tsx +++ b/src/renderer/src/components/editor/ChangesModeView.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment happy-dom -import { Suspense } from 'react' -import { act } from 'react' +import { act, Suspense } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { OpenFile } from '@/store/slices/editor' diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 417aaccf62e..15718697a54 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -4,7 +4,7 @@ import React, { useRef, useCallback, useEffect, useLayoutEffect, useMemo, useSta import Editor, { type OnMount } from '@monaco-editor/react' import type { editor } from 'monaco-editor' import { toast } from 'sonner' -import type { MarkdownDocument } from '../../../../shared/types' +import type { MarkdownDocument, DiffComment } from '../../../../shared/types' import { useAppStore } from '@/store' import { scrollTopCache, cursorPositionCache, setWithLRU } from '@/lib/scroll-cache' import '@/lib/monaco-setup' @@ -36,7 +36,6 @@ import { } from './monaco-markdown-doc-link-decorations' import { buildGitConflictDecorations, hasGitConflictMarkers } from './monaco-conflict-decorations' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import type { DiffComment } from '../../../../shared/types' import { isMarkdownComment } from '@/lib/diff-comment-compat' import { formatMarkdownReviewNotes, type MarkdownReviewNote } from '@/lib/markdown-review-notes' import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator' diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 39220edb95b..25b55305c06 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -20,8 +20,11 @@ import { deriveNotesSendAgentTargets, type NotesSendAgentTarget } from '@/lib/notes-send-agent-targets' -import { agentKindForAgentType, formatAgentTypeLabel } from '@/lib/agent-status' -import { agentTypeToIconAgent } from '@/lib/agent-status' +import { + agentKindForAgentType, + formatAgentTypeLabel, + agentTypeToIconAgent +} from '@/lib/agent-status' import { track } from '@/lib/telemetry' import { useNow } from '@/components/dashboard/useNow' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index c1ab843544e..fabdc6b4d43 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -32,9 +32,9 @@ function markdownAfterTextReplace(content: string, search: string, replacement: }) try { - let from: number | null = null + let from = -1 editor.state.doc.descendants((node, pos) => { - if (from !== null || !node.isText || !node.text) { + if (from !== -1 || !node.isText || !node.text) { return } const index = node.text.indexOf(search) @@ -42,7 +42,7 @@ function markdownAfterTextReplace(content: string, search: string, replacement: from = pos + index } }) - if (from === null) { + if (from === -1) { throw new Error(`Missing text: ${search}`) } editor.view.dispatch(editor.state.tr.insertText(replacement, from, from + search.length)) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 26cc45e7c19..d7c62b6e6ed 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -329,7 +329,7 @@ export function useEditorPanelContentState({ [file.id]: { kind: 'text', originalContent: '', - modifiedContent: `Error loading diff: ${err}`, + modifiedContent: `Error loading diff: ${String(err)}`, originalIsBinary: false, modifiedIsBinary: false } diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts b/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts index d18c744f45e..7f98c90520a 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef } from 'react' import { FEATURE_WALL_MAX_DWELL_MS } from '../../../../shared/feature-wall-telemetry' -import type { FeatureWallExitAction } from '../../../../shared/feature-wall-tour-depth' -import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth' +import type { + FeatureWallExitAction, + FeatureWallTourDepthSummary +} from '../../../../shared/feature-wall-tour-depth' import type { EventProps, FeatureWallOpenSourceTelemetry diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index a0699332738..d2ff07bfd57 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -10,7 +10,10 @@ import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' import { tuiAgentToAgentKind } from '@/lib/telemetry' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { + DEFAULT_DISABLED_TUI_AGENTS, + isTuiAgentEnabled +} from '../../../../shared/tui-agent-selection' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -49,7 +52,9 @@ export function FloatingTerminalWindowControls({ const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') - const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) + const disabledTuiAgents = useAppStore( + (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS + ) const defaultAgent = defaultTuiAgent && defaultTuiAgent !== 'blank' && diff --git a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx index af94be760b8..a8b20151a34 100644 --- a/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx +++ b/src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx @@ -7,9 +7,8 @@ import { Input } from '@/components/ui/input' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { useAppStore } from '@/store' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import type { GitHubWorkItemDetails } from '../../../../../shared/types' +import type { GitHubWorkItemDetails, GlobalSettings } from '../../../../../shared/types' import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' -import type { GlobalSettings } from '../../../../../shared/types' import { LabelsEditor } from './LabelsEditor' import { AssigneesEditor } from './AssigneesEditor' import { CommentsList, NewCommentForm } from './Comments' diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index 432e4e5f654..d2e7d21fa5b 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -1,7 +1,13 @@ import { translate } from '@/i18n/i18n' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -import { HeroFlow, HeroIntro, HeroPaired, type PairedDevice, type Platform } from './MobileHero' -import type { StepIndex } from './MobileHero' +import { + HeroFlow, + HeroIntro, + HeroPaired, + type PairedDevice, + type Platform, + type StepIndex +} from './MobileHero' import { getInstallCopy, type IosChannel } from './mobile-platform-copy' import type { MobilePageStage } from './mobile-page-stage' import { MobilePageToolbar } from './MobilePageToolbar' diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index eb92f1746f6..82d788e94f6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act } from 'react' +import { act, createElement, useRef, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { createElement, useRef, useState } from 'react' import { clearNativeChatAttachmentCacheForTests, readNativeChatAttachmentCache, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index 1f24874cdb7..158ab0bfa13 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act } from 'react' +import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { createElement } from 'react' import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' const mocks = vi.hoisted(() => ({ diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index fc72a23adee..cc2dc4c9264 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act } from 'react' +import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { createElement } from 'react' const mocks = vi.hoisted(() => ({ resolveNativeChatAttachmentOwner: vi.fn(), diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index a0fb9c00fd2..893e1c38fac 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { createElement, useEffect } from 'react' -import { act } from 'react' +import { createElement, useEffect, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultOnboardingState } from '../../../../shared/constants' diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx index d0b4f798a43..2b87618bd5e 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom import path from 'node:path' -import React, { type ReactNode, useState } from 'react' -import { act } from 'react' +import React, { type ReactNode, useState, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 0dd54d7b7dd..ab2716d1558 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -25,9 +25,9 @@ import { Pencil, SlidersHorizontal, Trash, - X + X, + ExternalLink } from 'lucide-react' -import { ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index cd64ea47dad..f9587ab8a08 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Plug, Files, GitBranch, ListChecks, PanelRight, Workflow } from 'lucide-react' import { useAppStore } from '@/store' -import type { ActiveRightSidebarTab } from '@/store/slices/editor' +import type { ActiveRightSidebarTab, ActivityBarPosition } from '@/store/slices/editor' import { useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' import { useSidebarResize } from '@/hooks/useSidebarResize' -import type { ActivityBarPosition } from '@/store/slices/editor' import { isFolderRepo } from '../../../../shared/repo-kind' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts index 2475a7d293e..4bae174619b 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.ts @@ -68,6 +68,7 @@ export function resolveBlockedCreateReviewNoticeMessage( case 'unsupported_provider': // Why: base_not_on_remote is a create-time hard failure surfaced as an error // result, not an inline-actionable eligibility state, so it is non-clickable. + // falls through case 'base_not_on_remote': case null: return null diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx index 6d3076d5c4f..0c0417a79ce 100644 --- a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx @@ -14,6 +14,7 @@ import { useAppStore } from '@/store' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { translate } from '@/i18n/i18n' import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' +import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' import { getOpenInEntryAvailability, getWorktreeOpenInEntries, @@ -42,7 +43,9 @@ export function SourceControlEntryContextMenu({ onOpenChange, children }: SourceControlEntryContextMenuProps): React.JSX.Element { - const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? []) + const openInApplications = useAppStore( + (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS + ) const settings = useAppStore((s) => s.settings) const fileManagerLabel = getLocalFileManagerLabel() const openInEntries = React.useMemo( diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index 88bac14374b..9558eb9a369 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -3,8 +3,7 @@ import { toast } from 'sonner' import { useConfirmationDialog } from '@/components/confirmation-dialog' import type { GitHubPRAutoMergeAction } from '@/components/github-pr-merge-state' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import type { PRInfo, Repo } from '../../../../shared/types' -import type { GitHubPRMergeMethod } from '../../../../shared/types' +import type { PRInfo, Repo, GitHubPRMergeMethod } from '../../../../shared/types' import { mergeGitHubHostedReview, setGitHubHostedReviewAutoMerge, diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index 8017aea4445..be2f142adff 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React from 'react' -import { act } from 'react' +import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 815a18abba3..1bc838642a0 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -13,10 +13,9 @@ import { getRuntimeRepoBaseRefDefault, searchRuntimeRepoBaseRefDetails } from '@/runtime/runtime-repo-client' -import type { Repo } from '../../../../shared/types' +import type { Repo, BaseRefSearchResult } from '../../../../shared/types' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' -import type { BaseRefSearchResult } from '../../../../shared/types' import { DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS, resolveSourceControlAiForOperation diff --git a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx index 69d420bbdca..e4f5adaa47f 100644 --- a/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorSettingsPane.tsx @@ -35,7 +35,7 @@ type EmulatorAvailability = { type MobileEmulatorSettingsPaneProps = { settings: GlobalSettings - updateSettings: (updates: Partial) => void + updateSettings: (updates: Partial) => Promise } const AUTOMATIC_DEVICE_VALUE = '__orca_automatic_emulator_device__' diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index c1f4390e5a8..6ca17d9fca3 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import React from 'react' -import { act } from 'react' +import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Repo } from '../../../../shared/types' diff --git a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx index 8bfacae4ad1..d0ee90eb03c 100644 --- a/src/renderer/src/components/settings/TerminalAppearanceSection.tsx +++ b/src/renderer/src/components/settings/TerminalAppearanceSection.tsx @@ -21,9 +21,8 @@ import { getTerminalWindowSearchEntries } from './terminal-search' import { Button } from '../ui/button' -import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls' +import { SettingsRow, SettingsSubsectionHeader, FontAutocomplete } from './SettingsFormControls' import { SearchableSetting } from './SearchableSetting' -import { FontAutocomplete } from './SettingsFormControls' import { TerminalFontSizeSetting } from './TerminalFontSizeSetting' import { TerminalAdvancedTypographyControls } from './TerminalAdvancedTypographyControls' import { TerminalThemeCatalogSection } from './TerminalThemeSections' diff --git a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx index acd8409e74d..31c1595a937 100644 --- a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx @@ -1,7 +1,10 @@ import { useState } from 'react' import { toast } from 'sonner' -import type { VoiceSettings } from '../../../../shared/speech-types' -import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types' +import type { + VoiceSettings, + SpeechModelManifest, + SpeechModelState +} from '../../../../shared/speech-types' import { Button } from '../ui/button' import { Label } from '../ui/label' import { diff --git a/src/renderer/src/components/settings/repository-source-control-ai-labels.ts b/src/renderer/src/components/settings/repository-source-control-ai-labels.ts index 7255b3e8bee..64f0a111080 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-labels.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-labels.ts @@ -5,10 +5,12 @@ import { type SourceControlActionId, type SourceControlActionRecipe } from '../../../../shared/source-control-ai-actions' -import type { RepoSourceControlAiOverrides } from '../../../../shared/source-control-ai-types' +import type { + RepoSourceControlAiOverrides, + SourceControlAiSettings +} from '../../../../shared/source-control-ai-types' import type { CustomAgentId } from '../../../../shared/commit-message-agent-spec' import { isCustomAgentId } from '../../../../shared/commit-message-agent-spec' -import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' export const ACTION_MODE_INHERIT = 'inherit' export const ACTION_MODE_OVERRIDE = 'override' diff --git a/src/renderer/src/components/settings/settings-search-keywords.ts b/src/renderer/src/components/settings/settings-search-keywords.ts index d9ee3003375..4a14a6862a2 100644 --- a/src/renderer/src/components/settings/settings-search-keywords.ts +++ b/src/renderer/src/components/settings/settings-search-keywords.ts @@ -1,5 +1,4 @@ -import { i18n } from '@/i18n/i18n' -import { translate } from '@/i18n/i18n' +import { i18n, translate } from '@/i18n/i18n' export type SearchKeywordSpec = { key: string diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 42e7ee20f82..93fee95eb45 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -80,7 +80,7 @@ vi.mock('@/components/ui/context-menu', () => ({ ) })) -import { +import SidebarNav, { getSetupGuideSidebarEntryReady, shouldShowAgentDashboardButton, shouldShowAgentsButton, @@ -88,7 +88,6 @@ import { shouldShowMobileButton, shouldShowSetupGuideEntry } from './SidebarNav' -import SidebarNav from './SidebarNav' function gitRepo(): Repo { return { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index ca60645185c..3887c52c79d 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { act, type ReactNode } from 'react' -import React from 'react' +import React, { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx index 0d6cf3245b4..d5242211256 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx @@ -13,6 +13,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { getExternalEditorOpenCapability } from '@/lib/external-editor-open-capability' +import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' import type { ShellOpenExternalEditorResult } from '../../../../shared/shell-open-types' import type { GlobalSettings, OpenInApplication } from '../../../../shared/types' import { translate } from '@/i18n/i18n' @@ -34,7 +35,7 @@ export type OpenInMenuEntry = { } export function getWorktreeOpenInEntries( - openInApplications: OpenInApplication[], + openInApplications: readonly OpenInApplication[], fileManagerLabel: string ): OpenInMenuEntry[] { return [ @@ -302,7 +303,9 @@ export function WorktreeOpenInMenuItems({ labelPrefix = '' }: WorktreeOpenInMenuItemsProps): React.JSX.Element { const openInWorktreePath = useOpenInWorktreePath({ worktreePath, connectionId }) - const openInApplications = useAppStore((s) => s.settings?.openInApplications ?? []) + const openInApplications = useAppStore( + (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS + ) const settings = useAppStore((s) => s.settings) const fileManagerLabel = getLocalFileManagerLabel() const entries = getWorktreeOpenInEntries(openInApplications, fileManagerLabel) diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index fecb07d0d48..db585ab64f7 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -197,6 +197,46 @@ export function barColor(usedPct: number): string { return 'bg-red-500' } +function ProviderRateLimitWindowSection({ + window, + label, + textClass, + mutedClass, + emptyBarClass, + usagePercentageDisplay +}: { + window: RateLimitWindow | null + label: string + textClass: string + mutedClass: string + emptyBarClass: string + usagePercentageDisplay: UsagePercentageDisplay +}): React.JSX.Element | null { + if (!window) { + return null + } + const usedPct = clampUsedPercent(window.usedPercent) + const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) + const resetLabel = window.resetsAt ? formatResetCountdown(window.resetsAt - Date.now()) : null + + return ( +
+
{label}
+
+ {/* Why: fill follows the selected percentage; color still signals consumption urgency. */} +
+
+
+ {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} + {resetLabel && {resetLabel}} +
+
+ ) +} + export function ProviderPanel({ p, inverted = false, @@ -268,38 +308,6 @@ export function ProviderPanel({ ? formatResetCreditExpiry(p.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null - const PanelWindowSection = ({ - w, - label - }: { - w: RateLimitWindow | null - label: string - }): React.JSX.Element | null => { - if (!w) { - return null - } - const usedPct = clampUsedPercent(w.usedPercent) - const displayedPct = getDisplayedUsagePercentage(usedPct, usagePercentageDisplay) - const resetLabel = w.resetsAt ? formatResetCountdown(w.resetsAt - Date.now()) : null - - return ( -
-
{label}
-
- {/* Why: fill follows the selected percentage; color still signals consumption urgency. */} -
-
-
- {formatUsagePercentageLabel(usedPct, usagePercentageDisplay)} - {resetLabel && {resetLabel}} -
-
- ) - } - return (
@@ -328,7 +336,15 @@ export function ProviderPanel({
{getWindowSections(p).map((s) => ( - + ))} {p.error ? ( diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 87d12c71218..d2e086f7496 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -10,7 +10,10 @@ import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { TuiAgent } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' -import { filterEnabledTuiAgents } from '../../../../shared/tui-agent-selection' +import { + DEFAULT_DISABLED_TUI_AGENTS, + filterEnabledTuiAgents +} from '../../../../shared/tui-agent-selection' import { translate } from '@/i18n/i18n' export type QuickLaunchAgentMenuItemsProps = { @@ -107,7 +110,9 @@ function QuickLaunchAgentMenuItemsInner({ const agentDetectionTarget = useAgentDetectionTargetForWorktree(worktreeId) const { detectedIds } = useDetectedAgents(agentDetectionTarget) const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent) - const disabledAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? []) + const disabledAgents = useAppStore( + (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS + ) const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index e9eb7a96f30..2ebecc589c9 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -207,7 +207,7 @@ function getTabLayoutSignature( return `${item.type}:${item.id}:${item.isPinned}:${isExpanded}:${Boolean(item.data.color)}:${label}` } if (item.type === 'browser') { - return `${item.type}:${item.id}:${item.isPinned}:${item.data.loading}:${item.data.loadError}:${label}` + return `${item.type}:${item.id}:${item.isPinned}:${item.data.loading}:${Boolean(item.data.loadError)}:${label}` } if (item.type === 'editor') { return `${item.type}:${item.id}:${item.isPinned}:${item.data.isDirty}:${item.data.isPreview}:${item.data.externalMutation ?? ''}:${status ?? ''}:${label}` diff --git a/src/renderer/src/components/tab-group/tab-drag-data.ts b/src/renderer/src/components/tab-group/tab-drag-data.ts new file mode 100644 index 00000000000..ad55f0edd10 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-data.ts @@ -0,0 +1,56 @@ +import type { TabGroup, TuiAgent } from '../../../../shared/types' +import type { TabSplitDirection } from '../../store/slices/tabs' + +export type TabDropZone = 'center' | TabSplitDirection + +export type TabDragItemData = { + kind: 'tab' + worktreeId: string + groupId: string + unifiedTabId: string + visibleTabId: string + tabType: 'terminal' | 'editor' | 'browser' | 'simulator' + label: string + iconPath?: string + color?: string | null + agent?: TuiAgent | null +} + +export type TabPaneDropData = { + kind: 'pane-body' + worktreeId: string + groupId: string +} + +export function canDropTabIntoPaneBody({ + activeDrag, + groupsByWorktree, + overGroupId, + worktreeId +}: { + activeDrag: TabDragItemData | null + groupsByWorktree: Record + overGroupId: string + worktreeId: string +}): boolean { + if (!activeDrag || activeDrag.worktreeId !== worktreeId) { + return false + } + + const overGroup = (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === overGroupId) + if (!overGroup) { + return false + } + + return activeDrag.groupId !== overGroupId || overGroup.tabOrder.length > 1 +} + +export function isTabDragData(value: unknown): value is TabDragItemData { + return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab' +} + +export function isPaneDropData(value: unknown): value is TabPaneDropData { + return ( + Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body' + ) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-target.ts b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts index b4c36a0dcdc..7b035ea2f5e 100644 --- a/src/renderer/src/components/tab-group/tab-drag-preview-target.ts +++ b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts @@ -1,5 +1,4 @@ -import type { TabDragItemData } from './useTabDragSplit' -import { isPaneDropData, isTabDragData } from './useTabDragSplit' +import { isPaneDropData, isTabDragData, type TabDragItemData } from './tab-drag-data' export type DragPreviewTabTarget = { groupId: string diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.ts b/src/renderer/src/components/tab-group/tab-drop-zone.ts index 10291e2389c..a30c592bc5a 100644 --- a/src/renderer/src/components/tab-group/tab-drop-zone.ts +++ b/src/renderer/src/components/tab-group/tab-drop-zone.ts @@ -1,4 +1,4 @@ -import type { TabDropZone } from './useTabDragSplit' +import type { TabDropZone } from './tab-drag-data' /** Matches TabGroupPanel tab row height (`h-[32px]`). */ export const TAB_GROUP_TAB_STRIP_HEIGHT_PX = 32 diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts index b78f383f26c..80fefad4080 100644 --- a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts @@ -11,7 +11,7 @@ import { isPaneDropData, isTabDragData, type TabDragItemData -} from './useTabDragSplit' +} from './tab-drag-data' export type TabGroupPanelGeometryEntry = { groupId: string diff --git a/src/renderer/src/components/tab-group/tab-insertion.ts b/src/renderer/src/components/tab-group/tab-insertion.ts index 708e4616b19..5ae5424036a 100644 --- a/src/renderer/src/components/tab-group/tab-insertion.ts +++ b/src/renderer/src/components/tab-group/tab-insertion.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core' -import type { TabDragItemData } from './useTabDragSplit' +import type { TabDragItemData } from './tab-drag-data' // Why: when a tab is dragged over another tab's sortable rect, we compute // which side of the hovered tab the drop will land on (before vs. after). diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 090e41b047f..ce79cf03281 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -14,9 +14,8 @@ import { useSensor, useSensors } from '@dnd-kit/core' -import type { TabGroup, TuiAgent } from '../../../../shared/types' +import type { TabGroup } from '../../../../shared/types' import { useAppStore } from '../../store' -import type { TabSplitDirection } from '../../store/slices/tabs' import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror' import { resolveTabInsertion, @@ -40,79 +39,34 @@ import { type ActivePaneColumnSplitTarget, type TabGroupPanelGeometrySnapshot } from './tab-group-panel-split-target' +import { + canDropTabIntoPaneBody, + isPaneDropData, + isTabDragData, + type TabDragItemData, + type TabDropZone +} from './tab-drag-data' export type { HoveredTabInsertion } - -export type TabDropZone = 'center' | TabSplitDirection +export { + canDropTabIntoPaneBody, + isPaneDropData, + isTabDragData, + type TabDragItemData, + type TabDropZone, + type TabPaneDropData +} from './tab-drag-data' // Why: tab activation waits for pointerup, so dnd-kit needs enough movement // tolerance to avoid treating ordinary click jitter as an intentional drag. export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12 -export type TabDragItemData = { - kind: 'tab' - worktreeId: string - groupId: string - unifiedTabId: string - visibleTabId: string - tabType: 'terminal' | 'editor' | 'browser' | 'simulator' - /** Rendered by the DragOverlay ghost that follows the cursor across - * groups. Source tab strips use overflow-hidden, so without the overlay - * the dragged tab would be invisible once the cursor leaves its own - * group's strip. */ - label: string - iconPath?: string - color?: string | null - /** Coding-harness agent running in a terminal tab, so the drag ghost shows - * the provider glyph and matches the resting tab. Resolved per-tab in - * SortableTab (not at the TabBar level) to avoid re-rendering the whole tab - * strip on every agent-status ping. */ - agent?: TuiAgent | null -} - -export type TabPaneDropData = { - kind: 'pane-body' - worktreeId: string - groupId: string -} - export type HoveredTabDropTarget = { groupId: string zone: TabDropZone panelRect?: DOMRect } -export function canDropTabIntoPaneBody({ - activeDrag, - groupsByWorktree, - overGroupId, - worktreeId -}: { - activeDrag: TabDragItemData | null - groupsByWorktree: Record - overGroupId: string - worktreeId: string -}): boolean { - if (!activeDrag || activeDrag.worktreeId !== worktreeId) { - return false - } - - const overGroup = (groupsByWorktree[worktreeId] ?? []).find((group) => group.id === overGroupId) - if (!overGroup) { - return false - } - - // Why: splitting the only tab in a group onto that same group's body is a - // visual no-op. The store already rejects that drop, so the hover layer must - // suppress the pane overlay too or the user sees a split affordance that can - // never produce a layout change. - if (activeDrag.groupId === overGroupId && overGroup.tabOrder.length <= 1) { - return false - } - - return true -} - export function canDropTabForPaneColumnSplit(args: { activeDrag: TabDragItemData | null groupsByWorktree: Record @@ -130,16 +84,6 @@ export function canDropTabForPaneColumnSplit(args: { }) } -export function isTabDragData(value: unknown): value is TabDragItemData { - return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab' -} - -export function isPaneDropData(value: unknown): value is TabPaneDropData { - return ( - Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body' - ) -} - const collisionDetection: CollisionDetection = (args) => { const pointerCollisions = pointerWithin(args) return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 2578d657569..1ea250715ce 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -666,6 +666,7 @@ export function useTerminalKeyboardShortcuts({ keyboardScopeRef, managerRef, paneTransportsRef, + panePtyBindingsRef, paneCwdRef, fallbackCwd, expandedPaneIdRef, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index caf324b08e2..4d6431880ed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -942,7 +942,7 @@ describe('connectPanePty', () => { afterEach(async () => { // Why: drain in-flight foreground-confirm microtasks while this test still owns the store mock, so its async fallout can't leak into (and flake) the next test. - await flushAsyncTicks() + await flushAsyncTicks(20) vi.useRealTimers() vi.restoreAllMocks() if (originalRequestAnimationFrame) { diff --git a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx index 3c13aea87e1..b10ba51caaf 100644 --- a/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx +++ b/src/renderer/src/components/terminal-pane/useSessionRestoredBannerDismiss.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment happy-dom -import { useRef } from 'react' -import { act } from 'react' +import { useRef, act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss' diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index ce77f8d1eba..59bce894d19 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -2,7 +2,8 @@ import { useRef, useState } from 'react' import type { Repo, TerminalQuickCommand, - TerminalQuickCommandScope + TerminalQuickCommandScope, + TuiAgent } from '../../../../shared/types' import { getTerminalQuickCommandAction, @@ -21,7 +22,6 @@ import { import { Label } from '@/components/ui/label' import { getAgentCatalog } from '@/lib/agent-catalog' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import type { TuiAgent } from '../../../../shared/types' import { TerminalQuickCommandActionToggle } from './TerminalQuickCommandActionToggle' import { TerminalQuickCommandAdvancedSection } from './TerminalQuickCommandAdvancedSection' import { TerminalQuickCommandContentSection } from './TerminalQuickCommandContentSection' diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 66b4ffd241f..dea9f82c245 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -16,7 +16,10 @@ import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoa import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' -import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' +import { + hasRegisteredRuntimeTerminalTab, + focusRuntimeTerminalSurface +} from '@/runtime/sync-runtime-graph' import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal' import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' @@ -72,7 +75,6 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { TOGGLE_QUICK_COMMANDS_MENU_EVENT } from '@/lib/quick-commands-menu-events' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index e9246310881..86b0472c9c4 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,4 +1,4 @@ -import type { TuiAgent } from '../../../shared/types' +import type { TuiAgent, GlobalSettings } from '../../../shared/types' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { useAppStore } from '@/store' import { @@ -12,7 +12,6 @@ import { } from '@/components/terminal-pane/terminal-bracketed-paste' import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' -import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index f8cfdebe10d..1e4e0aec2f0 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -3,10 +3,8 @@ import type { ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult } from '../../../shared/types' -import { - getEphemeralVmRecipeResultProjectRoot, - type EphemeralVmRecipeResultWarning -} from '../../../shared/ephemeral-vm-recipes' +import { getEphemeralVmRecipeResultProjectRoot } from '../../../shared/ephemeral-vm-recipes' +import type { EphemeralVmRecipeResultWarning } from '../../../shared/ephemeral-vm-recipe-diagnostics' import { PROJECT_HOST_SETUP_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { translate } from '@/i18n/i18n' import { assertRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts index 260cd2ad657..d050cbf1b06 100644 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ b/src/renderer/src/lib/github-work-item-background-create.ts @@ -29,8 +29,7 @@ import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version' -import type { GitHubWorkItem, SetupDecision } from '../../../shared/types' -import type { Repo } from '../../../shared/types' +import type { GitHubWorkItem, SetupDecision, Repo } from '../../../shared/types' import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' diff --git a/src/renderer/src/lib/open-in-application-selection.ts b/src/renderer/src/lib/open-in-application-selection.ts new file mode 100644 index 00000000000..09ccaca9bb9 --- /dev/null +++ b/src/renderer/src/lib/open-in-application-selection.ts @@ -0,0 +1,3 @@ +import type { OpenInApplication } from '../../../shared/types' + +export const NO_OPEN_IN_APPLICATIONS: readonly OpenInApplication[] = [] diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 2c1fd67de6d..4f409f16628 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -1,5 +1,4 @@ -import type { IDisposable, IMarker, Terminal } from '@xterm/xterm' -import type { ITerminalOptions } from '@xterm/xterm' +import type { IDisposable, IMarker, Terminal, ITerminalOptions } from '@xterm/xterm' import type { FitAddon } from '@xterm/addon-fit' import type { LigaturesAddon } from '@xterm/addon-ligatures' import type { SearchAddon } from '@xterm/addon-search' diff --git a/src/renderer/src/lib/repo-slug-index.ts b/src/renderer/src/lib/repo-slug-index.ts index 34c88bec363..ed1dbe5f5e1 100644 --- a/src/renderer/src/lib/repo-slug-index.ts +++ b/src/renderer/src/lib/repo-slug-index.ts @@ -15,8 +15,7 @@ // process (`repoSlug` reads `.git/config`). import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' -import type { Repo } from '../../../shared/types' -import type { GlobalSettings } from '../../../shared/types' +import type { Repo, GlobalSettings } from '../../../shared/types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { clearRepoSlugCacheValues, diff --git a/src/renderer/src/lib/worktree-operation-generation.ts b/src/renderer/src/lib/worktree-operation-generation.ts index 04a2bd3bfc6..67a09302371 100644 --- a/src/renderer/src/lib/worktree-operation-generation.ts +++ b/src/renderer/src/lib/worktree-operation-generation.ts @@ -1,6 +1,8 @@ import { parseExecutionHostId } from '../../../shared/execution-host' -import { getEnvironmentSshStateGeneration } from '@/store/slices/runtime-environment-ssh' -import { getEnvironmentSshTargetConnectionGeneration } from '@/store/slices/runtime-environment-ssh' +import { + getEnvironmentSshStateGeneration, + getEnvironmentSshTargetConnectionGeneration +} from '@/store/slices/runtime-environment-ssh' import { getLocalSshTargetConnectionGeneration } from '@/store/slices/ssh' import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index cb159aaaa91..9bb2b3bedd8 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -14,8 +14,10 @@ import type { } from '../../../shared/runtime-types' import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry' import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery' -import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' -import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { + SleepingAgentLaunchConfig, + AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' import { AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { AgentLaunchPreferences, diff --git a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts index b18cd48399d..d785560f393 100644 --- a/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts +++ b/src/renderer/src/store/slices/github-pr-refresh-host-guard.test.ts @@ -35,12 +35,15 @@ function createTestStore() { ) } -function seed(store: ReturnType, repo: Record) { +function seed( + store: ReturnType, + repo: Record & { id: string; path: string } +) { store.setState({ settings: { activeRuntimeEnvironmentId: null } as never, repos: [repo], worktreesByRepo: { - [repo.id as string]: [ + [repo.id]: [ { id: 'wt-1', repoId: repo.id, diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index 2ec22859950..e3a71e58fd6 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -6,8 +6,11 @@ import { callRuntimeRpc, clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { createRuntimeStatusSlice, type RuntimeStatusSlice } from './runtime-status' -import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' +import { + createRuntimeStatusSlice, + type RuntimeStatusSlice, + getRuntimeEnvironmentConnectionGeneration +} from './runtime-status' function createSliceStore() { return create()((...a) => ({ diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index b01292b9bda..2d8976d33cb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1064,7 +1064,7 @@ function writeWebKeybindingAction( bindings: string[] | null ): KeybindingFileSnapshot { if (!isKeybindingActionId(actionId)) { - throw new Error(`Unknown keybinding action "${actionId}".`) + throw new Error(`Unknown keybinding action "${String(actionId)}".`) } const normalizedBindings = bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 7701ea6b1fe..d26268d120e 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2266,6 +2266,7 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { switch (source) { case 'claude': // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. + // falls through case 'kimi': return eventName === 'UserPromptSubmit' case 'codex': @@ -2359,6 +2360,7 @@ function extractToolFields( switch (source) { case 'claude': // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + // falls through case 'kimi': return extractClaudeToolFields(eventName, hookPayload) case 'codex': diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 87bb9d38904..50581c5c919 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -186,6 +186,7 @@ export function extractAgentProviderSession( case 'gemini': case 'droid': // Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_). + // falls through case 'kimi': { const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index f9195147286..530c8442183 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -323,6 +323,7 @@ function buildAgentResumeInvocation( // Why: Kimi Code resumes with `kimi --session ` (alias `-S`). Sessions // are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is // required — resuming from another directory is rejected by the CLI. + // falls through case 'kimi': return `${baseCommand} --session ${sessionArg}` case 'copilot': @@ -337,6 +338,7 @@ function buildAgentResumeInvocation( case 'droid': // Why: OMP resumes by absolute transcript path (see buildAiVaultResumeCommand), // but the `--resume ` invocation form is identical to the others here. + // falls through case 'omp': return `${baseCommand} --resume ${sessionArg}` case 'antigravity': diff --git a/src/shared/ephemeral-vm-recipes.test.ts b/src/shared/ephemeral-vm-recipes.test.ts index b074c438327..d83ad44bb68 100644 --- a/src/shared/ephemeral-vm-recipes.test.ts +++ b/src/shared/ephemeral-vm-recipes.test.ts @@ -2,11 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from './pairing' import { EPHEMERAL_VM_RECIPE_JSON_STRUCTURE_LIMITS, + parseEphemeralVmRecipeResult +} from './ephemeral-vm-recipes' +import { getEphemeralVmRecipeResultWarnings, - parseEphemeralVmRecipeResult, redactEphemeralVmRecipeDiagnosticText, redactEphemeralVmRecipeResultForDiagnostics -} from './ephemeral-vm-recipes' +} from './ephemeral-vm-recipe-diagnostics' function makePairingCode(endpoint = 'wss://sandbox.example.com'): string { return encodePairingOffer({ diff --git a/src/shared/ephemeral-vm-recipes.ts b/src/shared/ephemeral-vm-recipes.ts index 0f68bc92f84..071f1e89c46 100644 --- a/src/shared/ephemeral-vm-recipes.ts +++ b/src/shared/ephemeral-vm-recipes.ts @@ -6,16 +6,6 @@ import { MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from './ssh-types' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -// Why: ephemeral-vm-recipe-doctor imports Node's fs/path, so it must NOT be -// re-exported through this barrel — the renderer/web-client imports this module -// and would otherwise pull Node built-ins into the browser bundle (build fails). -// Node callers import doctorEphemeralVmRecipe directly from the doctor module. -export { - getEphemeralVmRecipeResultWarnings, - redactEphemeralVmRecipeDiagnosticText, - redactEphemeralVmRecipeResultForDiagnostics -} from './ephemeral-vm-recipe-diagnostics' -export type { EphemeralVmRecipeResultWarning } from './ephemeral-vm-recipe-diagnostics' const JsonValueSchema: z.ZodType = z.lazy(() => z.union([ diff --git a/src/shared/external-worktree-inbox.ts b/src/shared/external-worktree-inbox.ts index 6b6fdaa74aa..56aea01ff8b 100644 --- a/src/shared/external-worktree-inbox.ts +++ b/src/shared/external-worktree-inbox.ts @@ -3,7 +3,7 @@ import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types import { effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility -} from './worktree-ownership' +} from './external-worktree-visibility' export function normalizeExternalWorktreeInboxPath(path: string): string { return normalizeRuntimePathForComparison(path) diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index d1a5d4be55c..be5d24a524c 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -1,5 +1,7 @@ import { normalizeRuntimePathSeparators } from './cross-platform-path' +import type { ExternalWorktreeVisibility, Repo } from './types' +export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23) export const UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH = 'Unknown location' function trimRuntimePathTrailingSlash(value: string): string { @@ -42,3 +44,26 @@ export function getExternalWorktreeParentPath(worktreePath: string | undefined): } return normalized.slice(0, lastSeparatorIndex) } + +export function isLegacyRepoForExternalWorktreeVisibility(repo: Repo): boolean { + if (typeof repo.externalWorktreeVisibilityLegacy === 'boolean') { + return repo.externalWorktreeVisibilityLegacy + } + if (repo.externalWorktreeVisibility === undefined) { + return true + } + if (!Number.isFinite(repo.addedAt)) { + return true + } + return repo.addedAt < EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT +} + +export function effectiveExternalWorktreeVisibility( + repo: Pick, + isLegacyRepoForVisibility: boolean +): ExternalWorktreeVisibility { + if (repo.externalWorktreeVisibility) { + return repo.externalWorktreeVisibility + } + return isLegacyRepoForVisibility ? 'show' : 'hide' +} diff --git a/src/shared/remote-runtime-shared-control-protocol.ts b/src/shared/remote-runtime-shared-control-protocol.ts index e4caf38dac6..4daae215989 100644 --- a/src/shared/remote-runtime-shared-control-protocol.ts +++ b/src/shared/remote-runtime-shared-control-protocol.ts @@ -1,5 +1,4 @@ -import { decrypt } from './e2ee-crypto' -import { encrypt } from './e2ee-crypto' +import { decrypt, encrypt } from './e2ee-crypto' import type WebSocket from 'ws' import { RemoteRuntimeClientError } from './remote-runtime-client' import { serializeRemoteRuntimePayload } from './remote-runtime-memory-limits' diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 46679391536..2ef27632896 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -23,9 +23,9 @@ import type { Worktree, WorktreeLineage, WorkspaceLineage, - WorktreeLineageWarning + WorktreeLineageWarning, + TerminalPaneLayoutNode } from './types' -import type { TerminalPaneLayoutNode } from './types' import type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 1b04a05282f..a14523c4d8f 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -12,9 +12,12 @@ import { type AgentScratchWorktreePathMatcher } from './agent-scratch-worktrees' import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox' +import { + effectiveExternalWorktreeVisibility, + isLegacyRepoForExternalWorktreeVisibility +} from './external-worktree-visibility' import type { DetectedWorktree, - ExternalWorktreeVisibility, GlobalSettings, OrcaWorkspaceLayout, Repo, @@ -23,30 +26,11 @@ import type { WorktreeOwnership } from './types' -export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23) - -export function isLegacyRepoForExternalWorktreeVisibility(repo: Repo): boolean { - if (typeof repo.externalWorktreeVisibilityLegacy === 'boolean') { - return repo.externalWorktreeVisibilityLegacy - } - if (repo.externalWorktreeVisibility === undefined) { - return true - } - if (!Number.isFinite(repo.addedAt)) { - return true - } - return repo.addedAt < EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT -} - -export function effectiveExternalWorktreeVisibility( - repo: Pick, - isLegacyRepoForVisibility: boolean -): ExternalWorktreeVisibility { - if (repo.externalWorktreeVisibility) { - return repo.externalWorktreeVisibility - } - return isLegacyRepoForVisibility ? 'show' : 'hide' -} +export { + effectiveExternalWorktreeVisibility, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + isLegacyRepoForExternalWorktreeVisibility +} from './external-worktree-visibility' export function buildKnownOrcaWorkspaceLayouts( settings: Pick, diff --git a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs index 137bc48dee1..3702ad21a9f 100644 --- a/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs +++ b/tests/e2e/fixtures/visible-tui-scroll-fixture.cjs @@ -69,7 +69,7 @@ process.stdin.on('data', (chunk) => { cleanup() } - pending += chunk + pending += chunk.toString() let match let lastIndex = 0 let reportsInChunk = 0 diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index a0ced05497f..9d23fe6002c 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -1,5 +1,4 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { realpathSync } from 'node:fs' +import { mkdirSync, writeFileSync, realpathSync } from 'node:fs' import path from 'node:path' import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' From 89968a10614d0e5f5a6b7805c81dccc3a1b5110b Mon Sep 17 00:00:00 2001 From: Mark Xian Date: Tue, 28 Jul 2026 12:43:41 +0800 Subject: [PATCH 06/53] fix(preflight): refresh Windows PATH on forced CLI checks (#10091) Refresh the persisted Windows PATH during preflight without blocking Electron's main thread. Bound and deduplicate registry reads, preserve the last good cache on failure, skip host refresh for WSL, and add Windows regression coverage. --- ...ight-agent-detection-no-subprocess.test.ts | 3 + ...eflight-windows-path-refresh.repro.test.ts | 103 ++++++++ src/main/ipc/preflight.test.ts | 72 ++++++ src/main/ipc/preflight.ts | 8 +- src/main/pty/windows-environment-path.test.ts | 177 +++++++++++++- src/main/pty/windows-environment-path.ts | 221 +++++++++++++++--- src/main/win32-utils.test.ts | 15 ++ src/main/win32-utils.ts | 9 +- 8 files changed, 568 insertions(+), 40 deletions(-) create mode 100644 src/main/ipc/preflight-windows-path-refresh.repro.test.ts diff --git a/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts b/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts index 2764cb647ca..851981d45aa 100644 --- a/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts +++ b/src/main/ipc/preflight-agent-detection-no-subprocess.test.ts @@ -17,6 +17,7 @@ const { getAzureDevOpsAuthStatusMock, getGiteaAuthStatusMock, detectCommandsInInstallDirsMock, + mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -29,6 +30,7 @@ const { getAzureDevOpsAuthStatusMock: vi.fn(), getGiteaAuthStatusMock: vi.fn(), detectCommandsInInstallDirsMock: vi.fn(), + mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -60,6 +62,7 @@ vi.mock('./local-agent-install-dir-detection', () => ({ // Win32 preflight env merge reads persisted registry PATH; stub it out. vi.mock('../pty/windows-environment-path', () => ({ + mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPath: mergePersistedWindowsPathMock })) diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts new file mode 100644 index 00000000000..38e135d4558 --- /dev/null +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -0,0 +1,103 @@ +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ + registryQueryAsyncMock: vi.fn(), + registryQuerySyncMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal>() + const originalExecFile = original.execFile as ( + command: string, + commandArgs: string[], + commandOptions: unknown, + commandCallback: (error: Error | null, stdout: string, stderr: string) => void + ) => unknown + const registryAwareExecFile = ( + file: string, + args: string[], + options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ): unknown => { + if (file.toLowerCase().endsWith('\\reg.exe')) { + return registryQueryAsyncMock(file, args, options, callback) + } + return originalExecFile(file, args, options, callback) + } + const customPromisify = Symbol.for('nodejs.util.promisify.custom') + Object.defineProperty(registryAwareExecFile, customPromisify, { + value: (originalExecFile as unknown as Record)[customPromisify] + }) + return { + ...original, + execFile: registryAwareExecFile, + execFileSync: registryQuerySyncMock + } +}) + +import { + __resetPersistedWindowsPathCacheForTests, + mergePersistedWindowsPathAsync +} from '../pty/windows-environment-path' +import { execLocalPreflightCommand } from './preflight-command-exec' + +describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { + const originalPath = process.env.Path ?? process.env.PATH ?? '' + const fixtureDirs: string[] = [] + + afterEach(() => { + process.env.Path = originalPath + registryQueryAsyncMock.mockReset() + registryQuerySyncMock.mockReset() + __resetPersistedWindowsPathCacheForTests() + for (const directory of fixtureDirs.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('finds a newly installed executable immediately after a forced refresh', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-path-refresh-')) + fixtureDirs.push(directory) + const command = 'orca-path-refresh-fixture.exe' + copyFileSync( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe'), + join(directory, command) + ) + + let persistedUserPath = '' + registryQuerySyncMock.mockImplementation((_file, args: string[]) => { + const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' + return ` Path REG_SZ ${value}\r\n` + }) + registryQueryAsyncMock.mockImplementation( + ( + _file: string, + args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ) => { + const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' + callback(null, ` Path REG_SZ ${value}\r\n`, '') + return {} as never + } + ) + __resetPersistedWindowsPathCacheForTests() + + await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ + code: 'ENOENT' + }) + + persistedUserPath = directory + const refreshOptions = { forceRefresh: true } + await mergePersistedWindowsPathAsync(process.env, refreshOptions) + + await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ + stdout: expect.any(String) + }) + expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) + expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 2966e1fbef6..fead0c97c86 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -15,6 +15,7 @@ const { getGiteaAuthStatusMock, resolveCliCommandsMock, isCommandOnLocalPathMock, + mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -28,6 +29,7 @@ const { getGiteaAuthStatusMock: vi.fn(), resolveCliCommandsMock: vi.fn(), isCommandOnLocalPathMock: vi.fn(), + mergePersistedWindowsPathAsyncMock: vi.fn(), mergePersistedWindowsPathMock: vi.fn() })) @@ -64,6 +66,7 @@ vi.mock('./command-path-resolver', () => ({ })) vi.mock('../pty/windows-environment-path', () => ({ + mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock, mergePersistedWindowsPath: mergePersistedWindowsPathMock })) @@ -122,6 +125,8 @@ describe('preflight', () => { getBitbucketAuthStatusMock.mockReset() getAzureDevOpsAuthStatusMock.mockReset() getGiteaAuthStatusMock.mockReset() + mergePersistedWindowsPathAsyncMock.mockReset() + mergePersistedWindowsPathAsyncMock.mockResolvedValue(undefined) mergePersistedWindowsPathMock.mockReset() // Why: existing tests should keep treating `which` as the only source // unless a case explicitly exercises the install-dir fallback. @@ -438,6 +443,73 @@ describe('preflight', () => { expect(execFileAsyncMock).toHaveBeenCalledTimes(10) }) + it('awaits the persisted Windows Path refresh before a forced host CLI preflight', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + let finishRefresh!: () => void + mergePersistedWindowsPathAsyncMock.mockImplementation( + () => + new Promise((resolve) => { + finishRefresh = resolve + }) + ) + execFileAsyncMock + .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' }) + .mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' }) + .mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' }) + .mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' }) + + const check = runPreflightCheck(true) + await Promise.resolve() + + expect(execFileAsyncMock).not.toHaveBeenCalled() + finishRefresh() + await expect(check).resolves.toMatchObject({ + gh: { installed: true, authenticated: true } + }) + + expect(mergePersistedWindowsPathAsyncMock).toHaveBeenNthCalledWith(1, process.env, { + forceRefresh: true + }) + }) + + it('does not refresh host Windows Path for forced WSL preflight', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command === 'wsl.exe') { + const script = String(args[5]) + if (script.includes('git') && script.includes('--version')) { + return { stdout: 'git version 2.0.0\n' } + } + if (script.includes('gh') && script.includes('--version')) { + return { stdout: 'gh version 2.0.0\n' } + } + if (script.includes('glab') && script.includes('--version')) { + return { stdout: 'glab version 1.92.1\n' } + } + if (script.includes('gh') && script.includes('auth status')) { + return { stdout: 'github.com\n - Active account: true\n' } + } + if (script.includes('glab') && script.includes('auth status')) { + return { stdout: 'Logged in to gitlab.com\n' } + } + } + throw new Error(`unexpected command ${String(command)}`) + }) + + await expect(runPreflightCheck(true, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({ + gh: { installed: true, authenticated: true } + }) + + expect(mergePersistedWindowsPathAsyncMock).not.toHaveBeenCalled() + }) + it('registers the preflight handler', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' }) diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 6a33e112fa2..5619495c1cf 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -5,6 +5,7 @@ import { getAzureDevOpsAuthStatus } from '../azure-devops/client' import { getBitbucketAuthStatus } from '../bitbucket/client' import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' +import { mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' import { getActiveMultiplexer } from './ssh' import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection' import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection' @@ -228,11 +229,16 @@ export async function runPreflightCheck( force = false, context?: PreflightRuntimeContext ): Promise { - const cacheable = !getPreflightWslTarget(context) + const wslTarget = getPreflightWslTarget(context) + const cacheable = !wslTarget if (cacheable && cached && !force) { return cached } + if (process.platform === 'win32' && !wslTarget) { + await mergePersistedWindowsPathAsync(process.env, { forceRefresh: force }) + } + if (force) { // Why: the GitLab known-hosts cache (gl-utils) is populated lazily on the // first GitLab request and never invalidated within a session. A user who diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index 236b21d4b25..69b502cdaf5 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,19 +1,25 @@ import { describe, expect, it, vi } from 'vitest' -const { defaultExecFileSyncMock } = vi.hoisted(() => ({ +const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ + defaultExecFileMock: vi.fn(), defaultExecFileSyncMock: vi.fn() })) vi.mock('node:child_process', () => ({ + execFile: defaultExecFileMock, execFileSync: defaultExecFileSyncMock })) import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPath, - readPersistedWindowsPathSegments + mergePersistedWindowsPathAsync, + readPersistedWindowsPathSegments, + readPersistedWindowsPathSegmentsAsync } from './windows-environment-path' +type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void + describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { const execFileSync = vi @@ -39,6 +45,10 @@ describe('readPersistedWindowsPathSegments', () => { }) expect(segments).toEqual(['C:\\Windows\\System32', 'C:\\Tools', 'C:\\Users\\me\\bin']) + expect(execFileSync).toHaveBeenCalledTimes(2) + expect( + execFileSync.mock.calls.every(([command]) => command === 'C:\\Windows\\System32\\reg.exe') + ).toBe(true) }) it('returns an empty list outside Windows', () => { @@ -48,6 +58,11 @@ describe('readPersistedWindowsPathSegments', () => { expect(execFileSync).not.toHaveBeenCalled() }) + it('does not start asynchronous registry reads outside Windows', async () => { + await expect(readPersistedWindowsPathSegmentsAsync({ platform: 'linux' })).resolves.toEqual([]) + expect(defaultExecFileMock).not.toHaveBeenCalled() + }) + it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -66,6 +81,164 @@ describe('readPersistedWindowsPathSegments', () => { Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) + + it('force-refreshes the production registry cache', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ + 'C:\\Machine', + 'C:\\User', + 'C:\\Program Files\\GitHub CLI' + ]) + expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps the last good segments when a forced read hits a blocked registry', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + .mockImplementation(() => { + throw new Error('ERROR: Access is denied.') + }) + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('still clears the cache when the registry reports an empty Path', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileSyncMock + .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') + .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + .mockReturnValue(' Path REG_SZ \r\n') + __resetPersistedWindowsPathCacheForTests() + + try { + expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) + + // Why: an emptied persisted Path is a successful read, not a blocked one — + // it must not be mistaken for the failure case and served from the cache. + expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([]) + expect(readPersistedWindowsPathSegments()).toEqual([]) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileSyncMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const callbacks: ExecCallback[] = [] + defaultExecFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callbacks.push(callback) + return {} as never + } + ) + __resetPersistedWindowsPathCacheForTests() + + try { + const firstEnv = { Path: 'C:\\First' } + const secondEnv = { Path: 'C:\\Second' } + const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) + const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) + + expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') + callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') + await Promise.all([first, second]) + expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') + expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('keeps the last good cache when bounded asynchronous reads time out', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + defaultExecFileMock + .mockImplementationOnce( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, ' Path REG_SZ C:\\Machine\r\n', '') + return {} as never + } + ) + .mockImplementationOnce( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(null, ' Path REG_SZ C:\\User\r\n', '') + return {} as never + } + ) + .mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { + callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') + return {} as never + } + ) + __resetPersistedWindowsPathCacheForTests() + + try { + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + await expect(readPersistedWindowsPathSegmentsAsync()).resolves.toEqual([ + 'C:\\Machine', + 'C:\\User' + ]) + expect(defaultExecFileMock).toHaveBeenCalledTimes(4) + expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + } finally { + __resetPersistedWindowsPathCacheForTests() + defaultExecFileMock.mockReset() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) describe('mergePersistedWindowsPath', () => { diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 283367c1c68..df0b53d3476 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,19 +1,29 @@ -import { execFileSync } from 'node:child_process' +import { execFile, execFileSync } from 'node:child_process' +import { getRegExePath } from '../win32-utils' +type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync type ReadWindowsPathOptions = { + execFile?: ExecFile execFileSync?: ExecFileSync env?: NodeJS.ProcessEnv + forceRefresh?: boolean platform?: NodeJS.Platform } +type RegistryPathRead = { + failed: boolean + segments: string[] +} + const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], ['HKCU\\Environment', 'Path'] ] as const const PERSISTED_WINDOWS_PATH_CACHE_TTL_MS = 30_000 +const PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS = 5_000 let persistedWindowsPathCache: | { @@ -21,6 +31,7 @@ let persistedWindowsPathCache: segments: string[] } | undefined +let pendingPersistedWindowsPathRefresh: Promise | undefined function parseRegistryPathValue(output: string, valueName: string): string | null { const valuePattern = new RegExp(`^\\s*${valueName}\\s+REG_\\w+\\s+(.*)$`, 'i') @@ -52,6 +63,60 @@ function splitPathSegments(pathValue: string, pathDelimiter: string): string[] { .filter(Boolean) } +function registryOutputSegments( + output: string, + valueName: string, + env: NodeJS.ProcessEnv, + pathDelimiter: string +): string[] { + const value = parseRegistryPathValue(output, valueName) + return value + ? splitPathSegments(expandWindowsEnvironmentVariables(value, env), pathDelimiter) + : [] +} + +function keepLastGoodSegments(segments: string[], failedReads: number): string[] { + if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { + return [...persistedWindowsPathCache.segments] + } + return segments +} + +function cachePersistedWindowsPathSegments(segments: string[], failedReads: number): string[] { + // Why: timeouts or policy blocks must not replace a usable cache, while successful empty + // registry values still need to remove stale entries. + const kept = keepLastGoodSegments(segments, failedReads) + persistedWindowsPathCache = { readAt: Date.now(), segments: [...kept] } + return [...kept] +} + +function readRegistryPathAsync( + run: ExecFile, + executable: string, + registryValue: readonly [key: string, valueName: string], + env: NodeJS.ProcessEnv, + pathDelimiter: string +): Promise { + const [key, valueName] = registryValue + return new Promise((resolve) => { + run( + executable, + ['query', key, '/v', valueName], + { encoding: 'utf8', timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + if (error) { + resolve({ failed: true, segments: [] }) + return + } + resolve({ + failed: false, + segments: registryOutputSegments(String(stdout), valueName, env, pathDelimiter) + }) + } + ) + }) +} + export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions = {}): string[] { const platform = options.platform ?? process.platform if (platform !== 'win32') { @@ -59,11 +124,13 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions } const useProductionCache = + options.execFile === undefined && options.execFileSync === undefined && options.env === undefined && options.platform === undefined const now = Date.now() if ( + !options.forceRefresh && useProductionCache && persistedWindowsPathCache && now - persistedWindowsPathCache.readAt < PERSISTED_WINDOWS_PATH_CACHE_TTL_MS @@ -71,44 +138,131 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } + if ( + !options.forceRefresh && + useProductionCache && + pendingPersistedWindowsPathRefresh && + persistedWindowsPathCache + ) { + // Why: synchronous PTY construction cannot await the active refresh; its stale cache is + // safer than duplicating the registry read on Electron's main thread. + return [...persistedWindowsPathCache.segments] + } + const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) const segments: string[] = [] + let failedReads = 0 for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { try { - const output = run('reg.exe', ['query', key, '/v', valueName], { + const output = run(getRegExePath(env), ['query', key, '/v', valueName], { encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, windowsHide: true }) - const value = parseRegistryPathValue(output, valueName) - if (value) { - segments.push( - ...splitPathSegments(expandWindowsEnvironmentVariables(value, env), pathDelimiter) - ) - } + segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) } catch { // Registry access can fail in stripped test containers or remote-like // Windows contexts. Existing PATH remains the fallback in those cases. + failedReads += 1 } } - if (useProductionCache) { - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. - persistedWindowsPathCache = { - readAt: now, - segments: [...segments] - } + if (!useProductionCache) { + return segments } - return segments + // Why: local PTY spawn is a hot path on Windows, and each uncached read + // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps + // terminal bursts cheap while still picking up newly installed CLIs soon. + return cachePersistedWindowsPathSegments(segments, failedReads) +} + +export async function readPersistedWindowsPathSegmentsAsync( + options: ReadWindowsPathOptions = {} +): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return [] + } + + const useProductionCache = + options.execFile === undefined && + options.execFileSync === undefined && + options.env === undefined && + options.platform === undefined + const now = Date.now() + if ( + !options.forceRefresh && + useProductionCache && + persistedWindowsPathCache && + now - persistedWindowsPathCache.readAt < PERSISTED_WINDOWS_PATH_CACHE_TTL_MS + ) { + return [...persistedWindowsPathCache.segments] + } + if (useProductionCache && pendingPersistedWindowsPathRefresh) { + return [...(await pendingPersistedWindowsPathRefresh)] + } + + const run = options.execFile ?? execFile + const env = options.env ?? process.env + const pathDelimiter = getPathDelimiter(platform) + const executable = getRegExePath(env) + const refresh = Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) + ) + ).then((reads) => { + const segments = reads.flatMap((read) => read.segments) + if (!useProductionCache) { + return segments + } + return cachePersistedWindowsPathSegments(segments, reads.filter((read) => read.failed).length) + }) + + if (!useProductionCache) { + return refresh + } + pendingPersistedWindowsPathRefresh = refresh + try { + return [...(await refresh)] + } finally { + if (pendingPersistedWindowsPathRefresh === refresh) { + pendingPersistedWindowsPathRefresh = undefined + } + } } export function __resetPersistedWindowsPathCacheForTests(): void { persistedWindowsPathCache = undefined + pendingPersistedWindowsPathRefresh = undefined +} + +function mergeWindowsPathSegments( + env: NodeJS.ProcessEnv, + persistedSegments: string[], + platform: NodeJS.Platform, + sourceEnv: NodeJS.ProcessEnv +): void { + const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' + const pathDelimiter = getPathDelimiter(platform) + const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' + const currentSegments = splitPathSegments(currentPath, pathDelimiter) + const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) + const missing = persistedSegments.filter((segment) => { + const normalized = segment.toLowerCase() + if (existing.has(normalized)) { + return false + } + existing.add(normalized) + return true + }) + + if (missing.length > 0) { + env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) + } } export function mergePersistedWindowsPath( @@ -120,27 +274,22 @@ export function mergePersistedWindowsPath( return } - const pathKey = env.Path !== undefined ? 'Path' : env.PATH !== undefined ? 'PATH' : 'Path' - const pathDelimiter = getPathDelimiter(platform) const sourceEnv = options.env ?? process.env - const currentPath = env[pathKey] ?? sourceEnv.PATH ?? sourceEnv.Path ?? '' - const currentSegments = splitPathSegments(currentPath, pathDelimiter) - const existing = new Set(currentSegments.map((segment) => segment.toLowerCase())) - const missing = readPersistedWindowsPathSegments(options).filter((segment) => { - const normalized = segment.toLowerCase() - if (existing.has(normalized)) { - return false - } - existing.add(normalized) - return true - }) - - if (missing.length === 0) { - return - } - // Why: Windows broadcasts PATH changes to future processes, but a running // Electron app keeps its old environment. Append the persisted additions so // newly installed CLIs resolve without unexpectedly reordering existing PATH. - env[pathKey] = [...currentSegments, ...missing].join(pathDelimiter) + mergeWindowsPathSegments(env, readPersistedWindowsPathSegments(options), platform, sourceEnv) +} + +export async function mergePersistedWindowsPathAsync( + env: NodeJS.ProcessEnv, + options: ReadWindowsPathOptions = {} +): Promise { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + const sourceEnv = options.env ?? process.env + const persistedSegments = await readPersistedWindowsPathSegmentsAsync(options) + mergeWindowsPathSegments(env, persistedSegments, platform, sourceEnv) } diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 0a6d381d3c0..abf0b0964be 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getCmdExePath, + getRegExePath, getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript, @@ -42,6 +43,20 @@ describe('isWindowsBatchScript', () => { }) }) +describe('getRegExePath', () => { + it('falls back to a local absolute system path for unsafe roots', () => { + expect(getRegExePath({ SystemRoot: '' })).toBe('C:\\Windows\\System32\\reg.exe') + expect(getRegExePath({ SystemRoot: 'Windows' })).toBe('C:\\Windows\\System32\\reg.exe') + expect(getRegExePath({ SystemRoot: '\\\\server\\share' })).toBe( + 'C:\\Windows\\System32\\reg.exe' + ) + }) + + it('uses an absolute custom Windows root', () => { + expect(getRegExePath({ SystemRoot: 'D:\\Windows' })).toBe('D:\\Windows\\System32\\reg.exe') + }) +}) + describe('getSpawnArgsForWindows', () => { it('routes .cmd through cmd.exe with /d /c on win32', () => { const originalComSpec = process.env.ComSpec diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 264ed6e6604..4edc47b93a1 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process' -import { delimiter, join } from 'node:path' +import { delimiter, join, win32 } from 'node:path' import { existsSync } from 'node:fs' function execFileWithoutBlocking( @@ -31,6 +31,13 @@ export function getWhoamiExePath(): string { return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe` } +/** Absolute path because service-launched Electron can omit System32 from PATH. */ +export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string { + const systemRoot = env.SystemRoot?.trim() + const root = systemRoot && /^[a-z]:[\\/]/i.test(systemRoot) ? systemRoot : 'C:\\Windows' + return win32.join(root, 'System32', 'reg.exe') +} + /** * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). From e73b1a1dd096334a2a845fb7103fa982b3c52130 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:04:00 -0700 Subject: [PATCH 07/53] feat(new-workspace): type-ahead Project and Run-on pickers (#11062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(new-workspace): make the project picker a type-ahead field The Create-worktree Project slot read as bulky and unpolished: a label row, an add-project icon, a 36px outline trigger and a chevron, all spent before choosing anything — then a popover carrying its own *second* search box, two-line rows, and a footer that scrolled out of reach. The field is now the search. Typing filters in place, so the nested search box is gone. Exactly one row is armed at any time and Enter takes it; hovering arms, so pointer and keyboard drive one cursor rather than two competing highlights. Armed is tracked by row key, not index, so a list arriving late over SSH cannot slide a different project under a keypress the user already aimed. Rows are single-line at 28px with an on-row Enter cap that takes space only while armed, and "Add a new project" is pinned to the popover edge so it survives every state — scrolled, filtered to nothing, or no projects at all. Long names and deep paths degrade deliberately: the name keeps up to half the row and the path elides from its middle, so two monorepo siblings stay distinguishable as …/services/checkout-api vs -web where a flat truncate rendered both identically. Recency is derived from when each project last had a workspace created, which is the action this picker is about to repeat — no new store field. The shell keeps data-project-combobox-root + role=combobox and stays focusable, so the composer's initial-focus and project-required handlers still land on it. * fix(new-workspace): align, scroll and loosen the project picker Five fixes to the type-ahead picker, three reported and two found while checking for related breakage. Alignment: the name and its smaller detail line were centred as boxes, so the 12px path sat visibly high against the 14px name. Both now share a baseline, in the committed field and in every row. The dot mark and the Enter cap are chips rather than text, so they stay centred on the row. Scrolling: the mouse wheel did nothing over the list. The composer is a Radix Dialog, and react-remove-scroll cancels wheel events for portaled content outside the dialog's DOM tree — the scrollbar dragged fine but the wheel was dead. The old cmdk list carried a shim for exactly this; the plain scroll pane that replaced it did not, so it has its own now. Density: rows go 28px -> 32px, row text 13px -> 14px and detail 11px -> 12px, with a taller Add row and more air above section headings. Escape stranded a query: with the list closed but text still typed, Escape was ignored (it was gated on the list being open), leaving the field showing text that matched nothing and hid the committed project. Escape now always restores the committed display, and only bubbles when there is nothing to undo. Listbox ownership: options sat inside unroled section and scroll wrappers, which breaks the listbox -> option relationship assistive tech relies on. Sections are groups carrying the heading as their label, and the scroll pane is presentational. Both new behaviours are covered by tests verified to fail without the fix. * chore(tools): keep the project-picker design lab The exploration harness behind the picker rewrite: 16 interactive design variants rendered against the app's real tokens and shadcn primitives, so a prototype is a drop-in ProjectCombobox rather than a mockup. Worth keeping because the frames encode bugs that only reproduce in context. DialogFrame renders the picker inside a real Radix Dialog, which is the only way the react-remove-scroll wheel bug shows up; the fixtures carry duplicate display names and deep sibling paths that a naive truncate renders identically. Run with: npx vite --config tools/wt-picker-lab/vite.config.ts * fix(new-workspace): stop the project list flashing open, shrink its empty state Opening the picker read as a double flash. The shared popover surface is translucent and fades 0 -> 1, which is right over the app canvas but wrong here: this popover lands directly on the composer dialog, so for the length of the fade the Name field underneath showed straight through the list and you saw two layers at once. The list now uses an opaque surface and zooms without fading, so it is solid from the first frame. Every other popover keeps the blur and fade. The "No projects match your search." state was a 60px centred block sitting next to 32px rows, which read as a different kind of surface and made an empty result feel like an error. It is now sized and aligned like a row. The lab's dialog frame focused whatever Radix picked first, which popped the Add-project tooltip on open and masked the real problem; it now focuses the name field the way the real composer does. * fix(new-workspace): square mark, centred empty state, and keep the list open on tab-focus Four fixes, three reported and one found while sweeping for others. Square mark: the option dot had a `rounded-full` override, so a project read as a circle here and a square everywhere else (jump palette, sidebar). Drop the override and use RepoBadgeMark's own shape. Centred empty state: "No projects match your search." was left-aligned after being shrunk to row height; centre it. Tab-focus blinked the list shut: the field lives in the popover's anchor, not inside its content, so Radix's dismissable layer saw focus land "outside" and closed the list the instant you tabbed in. Focus and pointer events within this control no longer dismiss it; genuine outside events still do. Junk text could strand the field: typing a query that matched nothing and then clicking away left the text sitting there with the list closed, showing no project and no error. A query only means something while the list is open, so closing without committing now clears it. On pressing Create with no project: no change needed. The create gate has not depended on project selection since #4991, and both submit paths already call showProjectRequiredError(), which sets the inline message and turns the field red via aria-invalid. Verified end to end: the button is pressable, the press paints the field destructive, and the message appears beneath it. * feat(new-workspace): rebuild the Run-on picker to match the project picker "Run on" was the last composer field still built the old way: an outline trigger wrapping a cmdk list, two-line rows, and no way to search. It now matches the project picker, so the two fields in the same form read as one control. The field is the search — type to filter hosts, paths and recipes with no nested search box. Exactly one row is armed at a time and Enter takes it; hovering arms, so pointer and keyboard drive one cursor. Rows are 32px with the label and its path on a shared baseline, the path eliding from its middle so two deep sibling paths stay distinguishable. The popover surface is opaque and unfaded because it lands on the composer dialog, where a translucent fade shows the form underneath. Two behaviours the project picker doesn't have are preserved. Disconnected hosts keep their inline Connect action, tracked per host so one stalled connect never blocks the others, and the list stays open so the connecting state is visible. Two rows open nested lists rather than committing: VM recipes, and "Add host" pinned to the popover edge so it survives every state — scrolled, filtered to nothing, or with no hosts at all. Enter and ArrowRight open a submenu; Escape backs out one layer at a time. Extracted from NewWorkspaceComposerCard (-563 lines) into files that each stay under the line limit without a suppression. Tests: the run-target cases asserted cmdk internals (`[cmdk-item]`, aria-disabled, cmdk-separator) that no longer exist. Rewritten against behaviour and the listbox roles instead. All 22 composer tests pass, plus a live sweep of 11 interactions in a real dialog. * fix(new-workspace): drop the Enter cap, fix submenu hover, match the Add rows Three follow-ups on the two composer pickers. The ↵ cap on the hovered row is gone from both. On a run-target row it sat next to the Connect action and read as a second, competing affordance; the highlight already says what Enter will take. Submenu rows never highlighted under the pointer. They passed a hardcoded `armed={false}`, so the recipe list and the Add-host choices were the only rows in either picker with no hover state. They now track their own hover. "Add a new project" used a chunky FolderPlus where "Add host" uses a plain Plus. Both rows were already the same height and type, so matching the glyph is the whole difference. * fix(new-workspace): restore the folder glyph, two-line Add-host cards, quiet Connect rows Three follow-ups. "Add a new project" goes back to FolderPlus — matching "Add host"'s plain Plus made the two consistent but lost the glyph that says which kind of thing is being added. A disconnected host row no longer repeats its status. The Connect button already says the host isn't connected, so "Connect this host to set up projects" beside it was saying it twice. Rows without a Connect action keep their detail, since there it explains why the host can't run. The Add-host choices go back to two-line cards. Their descriptions explain what you're picking ("Use an existing machine over SSH" vs "Pair another Orca runtime"), unlike a host row's detail, which just labels a host you already recognise. RunTargetRow grows a `stacked` variant for that rather than making the single-line row do both jobs. * fix(new-workspace): give Run on the same vertical rhythm as the other fields Run on is nested inside the Project block so the two share its error and empty states, which also put it on that block's 4px internal spacing. It reads as its own field, so it sat noticeably tighter than the 16px gap every other field in the composer gets. Pad it to match. * refactor(new-workspace): share the type-ahead machinery between both pickers Project and Run on were built one after the other, so each grew its own copy of the same mechanics: query and open state, arming by row key, the arrow-key walk, scroll-the-armed-row-into-view, the react-remove-scroll wheel shim, and the closes-drops-the-query rule. Two copies of subtle behaviour is two places for it to drift. useTypeAheadCombobox now owns all of it. Callers pass a function that turns a query into row keys and get back the query, the armed key, and the movement helpers. Run on layers its submenu state on top by wrapping `close`, which is the only part that isn't shared. The two long class strings both files repeated verbatim — the field shell and the opaque unfaded popover surface — are named constants now, so the reason they differ from the stock popover recipe is written down once instead of implied by a duplicated literal. No behaviour change: 16,456 renderer tests pass, plus the 22-check live interaction sweep across both pickers in a real dialog. * fix(new-workspace): drop aria-expanded from option rows, remove the design lab `aria-expanded` isn't a supported prop on `role="option"`, so the submenu rows were claiming a state screen readers can't interpret there. `aria-haspopup` alone already says the row opens a menu. Removes tools/wt-picker-lab. It was the harness for exploring this redesign — 12 interactive variants — and it did its job, but the 11 that lost are dead code, and its prototypes were the only thing failing the react-doctor gate (5 errors, all in throwaway variants; the shipped pickers had none). --- .../NewWorkspaceComposerCard.test.tsx | 51 +- .../components/NewWorkspaceComposerCard.tsx | 572 +----------------- .../new-workspace/ProjectCombobox.test.tsx | 297 +++++++-- .../new-workspace/ProjectCombobox.tsx | 481 +++++++++------ .../new-workspace/ProjectComboboxRow.tsx | 146 +++++ .../new-workspace/RunTargetCombobox.tsx | 380 ++++++++++++ .../new-workspace/RunTargetComboboxRow.tsx | 175 ++++++ .../new-workspace/RunTargetField.tsx | 134 ++++ .../new-workspace/RunTargetSubmenus.tsx | 192 ++++++ .../project-combobox-matching.test.ts | 90 +++ .../project-combobox-matching.ts | 156 +++++ .../new-workspace/run-target-options.ts | 110 ++++ .../type-ahead-combobox-styles.ts | 16 + .../use-recent-project-ids.test.ts | 60 ++ .../new-workspace/use-recent-project-ids.ts | 38 ++ .../new-workspace/use-type-ahead-combobox.ts | 113 ++++ .../new-workspace/use-wheel-scrollable.ts | 54 ++ src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- 22 files changed, 2295 insertions(+), 830 deletions(-) create mode 100644 src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx create mode 100644 src/renderer/src/components/new-workspace/RunTargetCombobox.tsx create mode 100644 src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx create mode 100644 src/renderer/src/components/new-workspace/RunTargetField.tsx create mode 100644 src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx create mode 100644 src/renderer/src/components/new-workspace/project-combobox-matching.test.ts create mode 100644 src/renderer/src/components/new-workspace/project-combobox-matching.ts create mode 100644 src/renderer/src/components/new-workspace/run-target-options.ts create mode 100644 src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts create mode 100644 src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts create mode 100644 src/renderer/src/components/new-workspace/use-recent-project-ids.ts create mode 100644 src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts create mode 100644 src/renderer/src/components/new-workspace/use-wheel-scrollable.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index 35c7e2184ab..e7001fcc783 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -280,15 +280,18 @@ function changeInputValue(input: HTMLInputElement, value: string): void { } function openRunTargetPicker(container: HTMLElement): void { - const runTargetButton = container.querySelector('button[role="combobox"]') - expect(runTargetButton).toBeTruthy() - act(() => runTargetButton?.click()) + // The field is the search box; clicking its shell focuses it and opens the list. + const runTargetShell = container.querySelector( + 'div[data-run-target-combobox-root="true"]' + ) + expect(runTargetShell).toBeTruthy() + act(() => runTargetShell?.click()) } function findRunTargetItem(label: string): HTMLElement | undefined { - // Why: "Add host" is a pinned footer button (mirrors the Project combobox), not a cmdk row. + // Rows are listbox options; "Add host" is the pinned footer row (also an option). return [ - ...document.body.querySelectorAll('[cmdk-item], [data-run-target-add-host]') + ...document.body.querySelectorAll('[role="option"], [data-run-target-add-host]') ].find((item) => item.textContent?.includes(label)) } @@ -579,11 +582,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { const devboxItem = findRunTargetItem('Devbox') expect(devboxItem?.textContent).toContain('Project not set up on this host') - // Not-connected rows stay highlightable (not disabled) so they hover like the other - // items; a separator sets them off instead of a heading. - expect(devboxItem?.getAttribute('aria-disabled')).toBe('false') - expect(devboxItem?.getAttribute('data-disabled')).toBe('false') - expect(document.body.querySelector('[cmdk-separator]')).toBeTruthy() + // Not-connected rows stay highlightable (never `disabled`) so they hover like + // the other rows; they're quieted visually instead. + expect(devboxItem?.hasAttribute('data-disabled')).toBe(false) + expect(devboxItem?.getAttribute('role')).toBe('option') }) it('shows the run target picker for one ready setup so hosts can be added', () => { @@ -623,10 +625,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const devboxItem = findRunTargetItem('Devbox') - expect( - devboxItem?.getAttribute('aria-disabled') === 'true' || - devboxItem?.hasAttribute('data-disabled') - ).toBe(true) + expect(devboxItem).toBeTruthy() const connectButton = [...(devboxItem?.querySelectorAll('button') ?? [])].find((button) => button.textContent?.includes('Connect') ) @@ -723,10 +722,10 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { openRunTargetPicker(current.container) const addHost = findRunTargetItem('Add host') expect(addHost).toBeTruthy() - // Hovering the row (no click) opens its submenu so it feels like a menu. React derives - // onPointerEnter from a bubbling pointerover, which is what jsdom dispatches here. + // Hovering the row (no click) opens its submenu so it feels like a menu. + // Hover arms rows via mousemove, matching the project picker. act(() => { - addHost?.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })) + addHost?.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })) }) expect(findRunTargetItem('Add SSH host')).toBeTruthy() @@ -785,10 +784,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(current.container.textContent).toContain('Run on') expect(current.container.textContent).not.toContain('VM recipe') - const runTargetButton = - current.container.querySelector('button[role="combobox"]') - expect(runTargetButton).toBeTruthy() - act(() => runTargetButton?.click()) + openRunTargetPicker(current.container) expect(document.body.textContent).toContain('Per-Workspace Environment') const ephemeralVmItem = [ @@ -797,7 +793,7 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { expect(ephemeralVmItem).toBeTruthy() act(() => ephemeralVmItem?.click()) - const recipeItem = [...document.body.querySelectorAll('[cmdk-item]')].find( + const recipeItem = [...document.body.querySelectorAll('[role="option"]')].find( (item) => item.textContent?.includes('Vercel Sandbox') ) expect(recipeItem).toBeTruthy() @@ -839,12 +835,13 @@ describe('NewWorkspaceComposerCard folder task source mode', () => { onEphemeralVmRecipeChange: (recipeId) => recipeChanges.push(recipeId) }) - const runTargetButton = - current.container.querySelector('button[role="combobox"]') - expect(runTargetButton?.textContent).toContain('Per-Workspace Environment') - act(() => runTargetButton?.click()) + const runTargetShell = current.container.querySelector( + 'div[data-run-target-combobox-root="true"]' + ) + expect(runTargetShell?.textContent).toContain('Per-Workspace Environment') + openRunTargetPicker(current.container) - const builderItem = [...document.body.querySelectorAll('[cmdk-item]')].find( + const builderItem = [...document.body.querySelectorAll('[role="option"]')].find( (item) => item.textContent?.includes('Builder') ) expect(builderItem).toBeTruthy() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 4e89e8b0e44..edf657c3118 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1,33 +1,18 @@ /* eslint-disable max-lines -- Why: keep the full composer card markup together so the inline and modal variants share one UI surface. */ import React from 'react' -import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { AlertTriangle, Check, ChevronDown, - ChevronRight, - ChevronsUpDown, - Cloud, CornerDownLeft, FolderPlus, LoaderCircle, - Monitor, PlugZap, - Plus, - Settings2, - Server + Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandItem, - CommandList, - CommandSeparator -} from '@/components/ui/command' -import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { SettingsSwitch } from '@/components/settings/SettingsFormControls' import type RepoCombobox from '@/components/repo/RepoCombobox' @@ -63,6 +48,7 @@ import SmartWorkspaceNameField, { } from '@/components/new-workspace/SmartWorkspaceNameField' import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' +import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' import { AddRemoteHostDialog, type AddRemoteHostMode @@ -71,11 +57,9 @@ import type { SetupConfig } from '@/lib/new-workspace' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import type { NeedsSetupProjectHostOption, - ProjectHostSetupOption, - ReadyProjectHostSetupOption + ProjectHostSetupOption } from '@/lib/project-host-setup-options' import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { SshConnectionStatus } from '../../../shared/ssh-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeStatus } from '../../../shared/runtime-types' @@ -249,549 +233,6 @@ async function withUiConnectTimeout(promise: Promise): Promise { } } -function getRecipeCommandDisplay(command: string): string { - const trimmed = command.trim() - const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/) - return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed -} - -function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string { - if (recipe.destroyDisabled) { - return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled') - } - if (recipe.destroy) { - return translate( - 'auto.components.NewWorkspaceComposerCard.destroyConfigured', - 'destroy configured' - ) - } - return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy') -} - -type WorkspaceRunTargetComboboxProps = { - hostOptions: readonly ProjectHostSetupOption[] - hostValue: string | null - onHostChange?: (setupId: string) => void - recipes: EphemeralVmRecipeOption[] - recipeValue: string | null - onRecipeChange?: (recipeId: string | null) => void - onAddRemoteServer?: () => void - onAddSshHost?: () => void - onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise | void -} - -type HostPathTooltipPosition = { - left: number - top: number - maxWidth: number -} - -const HOST_PATH_TOOLTIP_DELAY_MS = 400 -const HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX = 8 -const HOST_PATH_TOOLTIP_TRIGGER_GAP_PX = 4 - -function HostPathTooltip({ path }: { path: string }): React.JSX.Element { - const tooltipId = React.useId() - const timerRef = React.useRef | null>(null) - const pointerInsideRef = React.useRef(false) - const [position, setPosition] = React.useState(null) - - const hideTooltip = React.useCallback((): void => { - pointerInsideRef.current = false - if (timerRef.current !== null) { - clearTimeout(timerRef.current) - timerRef.current = null - } - setPosition(null) - }, []) - - React.useEffect(() => hideTooltip, [hideTooltip]) - - const handlePointerEnter = React.useCallback((event: React.PointerEvent): void => { - pointerInsideRef.current = true - const trigger = event.currentTarget - if (timerRef.current !== null) { - clearTimeout(timerRef.current) - } - timerRef.current = setTimeout(() => { - timerRef.current = null - if (!pointerInsideRef.current || !trigger.isConnected) { - return - } - const rect = trigger.getBoundingClientRect() - // Why: anchor under the hovered path, capping width to the viewport edge so a long path wraps instead of flying off-screen. - const left = Math.max(HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX, rect.left) - setPosition({ - left, - top: rect.bottom + HOST_PATH_TOOLTIP_TRIGGER_GAP_PX, - maxWidth: window.innerWidth - left - HOST_PATH_TOOLTIP_VIEWPORT_GAP_PX - }) - }, HOST_PATH_TOOLTIP_DELAY_MS) - }, []) - - return ( - <> - {/* Trigger is the truncated path line itself, so the tooltip only appears when hovering it. */} -
- {path} -
- {/* Why: a fixed, pointer-transparent portal cannot reflow cmdk or become the hover target. */} - {position - ? createPortal( - , - document.body - ) - : null} - - ) -} - -// Why: the local machine isn't a server — give it a monitor glyph so it reads as "this computer". -function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element { - const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server - return -} - -function WorkspaceRunTargetCombobox({ - hostOptions, - hostValue, - onHostChange, - recipes, - recipeValue, - onRecipeChange, - onAddRemoteServer, - onAddSshHost, - onConnectHost -}: WorkspaceRunTargetComboboxProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) - const [vmRecipesOpen, setVmRecipesOpen] = React.useState(false) - const [hostActionsOpen, setHostActionsOpen] = React.useState(false) - // Why: track in-flight connects per host (a Set, not a single id) so connecting one host — or - // one connect stalling — never disables connecting to the others. - const [connectingHostIds, setConnectingHostIds] = React.useState>( - () => new Set() - ) - const readyHostOptions = React.useMemo( - () => - hostOptions.filter( - (option): option is ReadyProjectHostSetupOption => option.kind === 'ready' - ), - [hostOptions] - ) - const needsSetupHostOptions = React.useMemo( - () => - hostOptions.filter( - (option): option is NeedsSetupProjectHostOption => option.kind === 'needs-setup' - ), - [hostOptions] - ) - const selectedHost = - readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null - const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null - const selectedValue = selectedRecipe - ? `recipe:${selectedRecipe.id}` - : selectedHost - ? `host:${selectedHost.id}` - : '' - const ephemeralVmLabel = translate( - 'auto.components.NewWorkspaceComposerCard.ephemeralVm', - 'Per-Workspace Environment' - ) - - const handleHostSelect = React.useCallback( - (setupId: string): void => { - if (!readyHostOptions.some((candidate) => candidate.id === setupId)) { - return - } - onHostChange?.(setupId) - onRecipeChange?.(null) - setOpen(false) - }, - [onHostChange, onRecipeChange, readyHostOptions] - ) - - const handleRecipeSelect = React.useCallback( - (recipeId: string): void => { - if (!recipes.some((recipe) => recipe.id === recipeId)) { - return - } - onRecipeChange?.(recipeId) - setVmRecipesOpen(false) - setOpen(false) - }, - [onRecipeChange, recipes] - ) - - const connectHost = React.useCallback( - async (option: NeedsSetupProjectHostOption): Promise => { - if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) { - return - } - setConnectingHostIds((current) => new Set(current).add(option.hostId)) - try { - await onConnectHost(option) - } finally { - // Why: always clear the in-flight id when the connect settles (success, failure, or - // timeout) so the row's spinner stops. No mounted-guard — a stale setState on an - // unmounted component is a harmless no-op in React 18, and the guard's ref was the - // source of a StrictMode bug that left the spinner stuck. - setConnectingHostIds((current) => { - if (!current.has(option.hostId)) { - return current - } - const next = new Set(current) - next.delete(option.hostId) - return next - }) - } - }, - [connectingHostIds, onConnectHost] - ) - - // Why: the submenu rows open their popover on hover/focus (not just click) to feel like a - // menu; opening one closes the other so the two right-side popovers never stack. - const openVmRecipesSubmenu = React.useCallback((): void => { - setHostActionsOpen(false) - setVmRecipesOpen(true) - }, []) - - const openHostActionsSubmenu = React.useCallback((): void => { - setVmRecipesOpen(false) - setHostActionsOpen(true) - }, []) - - // Why: hovering/highlighting a plain host row dismisses any submenu popover left open from - // passing over the recipe/add-host rows, so a stray submenu can't linger over the list. - const closeSubmenus = React.useCallback((): void => { - setVmRecipesOpen(false) - setHostActionsOpen(false) - }, []) - - const handleAddSshHost = React.useCallback((): void => { - setHostActionsOpen(false) - setOpen(false) - onAddSshHost?.() - }, [onAddSshHost]) - - const handleAddRemoteServer = React.useCallback((): void => { - setHostActionsOpen(false) - setOpen(false) - onAddRemoteServer?.() - }, [onAddRemoteServer]) - - return ( - - - - - - - - - {translate( - 'auto.components.NewWorkspaceComposerCard.noRunTargets', - 'No run targets are ready for this project.' - )} - - {readyHostOptions.map((option) => ( - handleHostSelect(option.id)} - onPointerEnter={closeSubmenus} - onFocus={closeSubmenus} - className="items-center gap-2 px-3 py-1.5" - > - - -
-
{option.label}
- -
-
- ))} - {/* Why: not-connected hosts are dormant, not errors — a separator (no heading) sets - them off as a secondary group without labeling the recipe/add-host rows below. */} - {needsSetupHostOptions.length > 0 ? ( - <> - - {needsSetupHostOptions.map((option) => ( - // Why: setup-on-host is a follow-up; this row only explains why the host cannot - // run the workspace yet. It stays highlightable (no `disabled`) so it matches the - // hover of the other rows, but selecting it is a no-op since it isn't ready. - {}} - onPointerEnter={closeSubmenus} - onFocus={closeSubmenus} - className="items-center gap-2 px-3 py-1.5" - > -
- - {/* Why: show a spinner while connecting; otherwise reserve the alarm glyph for - a genuine connection error and give a dormant disconnected host the neutral - server icon. */} - {connectingHostIds.has(option.hostId) ? ( - - ) : option.attention ? ( - - ) : ( - - )} -
-
{option.label}
-
- {option.detail} -
-
-
- {option.connectAction && onConnectHost ? ( - - ) : null} -
- ))} - - ) : null} - {/* Why: separate the host list from the per-workspace-env row; the "Add host" action - is pinned below the list with its own border, so it needs no separator here. */} - {recipes.length > 0 && - (readyHostOptions.length > 0 || needsSetupHostOptions.length > 0) ? ( - - ) : null} - {recipes.length > 0 ? ( - - - {/* Why: a real CommandItem (not a raw button) so cmdk registers it — fixes missing rows, uneven height, and double-highlight. */} - - - -
-
{ephemeralVmLabel}
- {/* Why: a second line so this row matches the two-line host options above and hints what it opens. */} -
- {translate( - 'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint', - 'Provision an on-demand environment from a recipe' - )} -
-
- -
-
- - - - {recipes.map((recipe) => ( - handleRecipeSelect(recipe.id)} - className="items-center gap-2 px-3 py-1.5" - > - -
-
{recipe.name}
-
- {getRecipeCommandDisplay(recipe.create)} ·{' '} - {getRecipeDestroyLabel(recipe)} -
- {recipe.description ? ( -
- {recipe.description} -
- ) : null} -
-
- ))} -
-
-
-
- ) : null} -
- {/* Why: pin "Add host" below the scrollable list — mirrors the Project combobox's - "Add a new project" footer so it keeps a compact single-row height and one clean - divider instead of a taller in-list row above the popover edge. */} -
- - {/* Why: an Anchor (not a Trigger) so click/hover/focus all just open the submenu — - a Trigger's own toggle would fight the hover-open and close it on the same click. */} - - - - - {/* Why: pin an empty value so cmdk doesn't auto-highlight the first row on open — - matches the recipes submenu, which leaves nothing highlighted by default. */} - - - - -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addSshHost', - 'Add SSH host' - )} -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addSshHostHint', - 'Use an existing machine over SSH' - )} -
-
-
- - -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer', - 'Add Remote Orca Server' - )} -
-
- {translate( - 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint', - 'Pair another Orca runtime' - )} -
-
-
-
-
-
-
-
-
-
-
- ) -} - function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element { // Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and // the run-setup toggle live in the section header above, so the card carries no chrome of its @@ -1258,11 +699,14 @@ export default function NewWorkspaceComposerCard({

) : null} {shouldShowRunTargetPicker ? ( -
+ // Why: Run on is nested in the Project block (so they share the + // error/empty states), which put it on the block's 4px rhythm. It's + // its own field, so give it the 16px other fields get. +
- ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/command', () => ({ - Command: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandInput: React.forwardRef>( - (props, ref) => - ), - CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandItem: ({ + Popover: ({ children, - onSelect, - value + onOpenChange }: { children: React.ReactNode - onSelect?: (value: string) => void - value: string + onOpenChange?: (open: boolean) => void }) => ( - - ) +
+ ), + PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
})) +vi.mock('./use-recent-project-ids', () => ({ useRecentProjectIds: () => [] })) + let container: HTMLDivElement let root: Root @@ -66,6 +60,41 @@ const projects: NewWorkspaceProjectOption[] = [ } ] +function field(): HTMLInputElement { + const node = container.querySelector( + 'input[data-project-combobox-root="true"][role="combobox"]' + ) + if (!node) { + throw new Error('project combobox field not found') + } + return node +} + +function openList(): void { + act(() => { + field().dispatchEvent(new FocusEvent('focus', { bubbles: true })) + }) +} + +function rowFor(optionId: string): HTMLElement { + const row = Array.from(container.querySelectorAll('[role="option"]')).find((node) => + node.textContent?.includes(optionId) + ) + if (!row) { + throw new Error(`row not found for ${optionId}`) + } + return row +} + +function type(value: string): void { + const input = field() + act(() => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) @@ -87,9 +116,24 @@ describe('ProjectCombobox', () => { ) }) - const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') - expect(trigger?.textContent).toContain('orca') - expect(trigger?.textContent).not.toContain('SSH') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('orca') + expect(shell?.textContent).not.toContain('SSH') + }) + + it('keeps a focusable combobox that composer focus helpers can target', () => { + act(() => { + root.render() + }) + + const trigger = container.querySelector( + '[data-project-combobox-root="true"][role="combobox"]' + ) + expect(trigger).toBeTruthy() + act(() => { + trigger?.focus() + }) + expect(document.activeElement).toBe(trigger) }) it('selects projects by logical project id', () => { @@ -104,10 +148,9 @@ describe('ProjectCombobox', () => { /> ) }) + openList() act(() => { - container - .querySelector('[data-command-value="github:stablyai/noqa"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + rowFor('stablyai/noqa').dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') @@ -126,14 +169,13 @@ describe('ProjectCombobox', () => { ) }) - const trigger = container.querySelector('[data-project-combobox-root="true"][role="combobox"]') - expect(trigger?.textContent).toContain('Platform') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('Platform') + openList() expect(container.textContent).toContain('/tmp/platform') act(() => { - container - .querySelector('[data-command-value="project-group:folder-group"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + rowFor('/tmp/platform').dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onValueChange).toHaveBeenCalledWith('project-group:folder-group') @@ -152,23 +194,53 @@ describe('ProjectCombobox', () => { /> ) }) + openList() - const addButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent?.includes('Add a new project') + const addRow = Array.from(container.querySelectorAll('[role="option"]')).find( + (node) => node.textContent?.includes('Add a new project') ) - expect(addButton).toBeTruthy() + expect(addRow).toBeTruthy() act(() => { - addButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) expect(onAddProject).toHaveBeenCalledTimes(1) }) + it('keeps "Add a new project" reachable when the search matches nothing', () => { + const onAddProject = vi.fn() + + act(() => { + root.render( + + ) + }) + openList() + type('zzzznomatch') + + expect(container.textContent).toContain('No projects match your search.') + const addRow = Array.from(container.querySelectorAll('[role="option"]')).find( + (node) => node.textContent?.includes('Add a new project') + ) + expect(addRow).toBeTruthy() + + act(() => { + addRow?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onAddProject).toHaveBeenCalledTimes(1) + }) + it('omits the "Add a new project" action when no handler is provided', () => { act(() => { root.render() }) + openList() expect(container.textContent).not.toContain('Add a new project') }) @@ -198,8 +270,163 @@ describe('ProjectCombobox', () => { ) }) + openList() expect(container.textContent).toContain('/workspace/storefront/merchant') expect(container.textContent).toContain('/workspace/admin/merchant') }) + + it('filters as the user types, without a second search box', () => { + act(() => { + root.render() + }) + openList() + // The field itself is the only text input in the control. + expect(container.querySelectorAll('input')).toHaveLength(1) + + type('noq') + expect(container.textContent).toContain('stablyai/noqa') + expect(container.textContent).not.toContain('stablyai/orca') + }) + + it('commits the armed row on Enter', () => { + const onValueChange = vi.fn() + const onValueSelected = vi.fn() + + act(() => { + root.render( + + ) + }) + openList() + type('noq') + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa') + expect(onValueSelected).toHaveBeenCalledWith('github:stablyai/noqa') + }) + + it('arms "Add a new project" when a query matches nothing, so Enter is never a wrong guess', () => { + const onAddProject = vi.fn() + const onValueChange = vi.fn() + + act(() => { + root.render( + + ) + }) + openList() + type('zzzznomatch') + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + }) + + expect(onAddProject).toHaveBeenCalledTimes(1) + expect(onValueChange).not.toHaveBeenCalled() + }) + + it('restores the committed project on Escape instead of stranding a stale query', () => { + act(() => { + root.render( + + ) + }) + openList() + type('zzzznomatch') + expect(field().value).toBe('zzzznomatch') + // Radix closes the popover itself on Escape/outside-click, leaving a closed + // list with a live query — the state that used to strand the field showing + // text matching nothing while hiding the committed project. + act(() => { + container + .querySelector('[data-test-close-popover]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + act(() => { + field().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }) + + expect(field().value).toBe('') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('orca') + }) + + it('drops an uncommitted query when the list closes, so junk text never persists', () => { + act(() => { + root.render( + + ) + }) + openList() + type('asasdasd') + expect(field().value).toBe('asasdasd') + + // Blur / outside-click closes via Radix, not the key handler. + act(() => { + container + .querySelector('[data-test-close-popover]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(field().value).toBe('') + const shell = container.querySelector('[data-project-combobox-root="true"]') + expect(shell?.textContent).toContain('orca') + }) + + it('marks the field invalid so a failed create press can turn it red', () => { + act(() => { + root.render( + + ) + }) + + expect(field().getAttribute('aria-invalid')).toBe('true') + expect(field().getAttribute('aria-describedby')).toBe('project-error') + }) + + it('owns every option from the listbox, with no unroled wrapper in between', () => { + act(() => { + root.render( + + ) + }) + openList() + + const listbox = container.querySelector('[role="listbox"]') + expect(listbox).toBeTruthy() + const options = Array.from(listbox?.querySelectorAll('[role="option"]') ?? []) + expect(options.length).toBeGreaterThan(0) + for (const option of options) { + let parent = option.parentElement + while (parent && parent !== listbox) { + // A bare wrapper here breaks the listbox → option relationship for AT. + expect(parent.getAttribute('role')).toBeTruthy() + parent = parent.parentElement + } + } + }) }) diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx index f1dcea89f2a..9f29eb4c407 100644 --- a/src/renderer/src/components/new-workspace/ProjectCombobox.tsx +++ b/src/renderer/src/components/new-workspace/ProjectCombobox.tsx @@ -1,21 +1,18 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown, FolderOpen, FolderPlus } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import React, { useCallback, useMemo } from 'react' +import { ChevronDown, FolderPlus } from 'lucide-react' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { cn } from '@/lib/utils' -import { - searchNewWorkspaceProjectOptions, - type NewWorkspaceProjectOption -} from '@/lib/new-workspace-project-options' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' import { translate } from '@/i18n/i18n' +import { + getAmbiguousProjectOptionIds, + rankProjectOptions, + sectionProjectOptions +} from './project-combobox-matching' +import { ProjectOptionDetail, ProjectOptionMark, ProjectOptionRow } from './ProjectComboboxRow' +import { useRecentProjectIds } from './use-recent-project-ids' +import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox' +import { COMBOBOX_FIELD_SHELL, COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' type ProjectComboboxProps = { options: readonly NewWorkspaceProjectOption[] @@ -29,6 +26,16 @@ type ProjectComboboxProps = { describedBy?: string } +const ADD_PROJECT_KEY = 'add-project' +const ROOT_ATTRIBUTE = 'data-project-combobox-root' + +/** + * Type-ahead project picker: the field *is* the search, so there's no trigger + * wrapping a second search box. Exactly one row is armed at any time and Enter + * takes it; hovering arms, so the pointer and the keyboard drive one cursor. + * "Add a new project" is pinned to the popover edge so it stays reachable + * without scrolling, in every state including no-matches and no-projects. + */ export default function ProjectCombobox({ options, value, @@ -40,217 +47,303 @@ export default function ProjectCombobox({ invalid = false, describedBy }: ProjectComboboxProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [commandValue, setCommandValue] = useState('') - const inputRef = React.useRef(null) - const focusFrameRef = React.useRef(null) - const selectedProject = useMemo( - () => options.find((option) => option.id === value) ?? null, - [options, value] + const recentIds = useRecentProjectIds() + // Ranking depends on the query the hook owns, so rows are derived from it and + // handed back; `matches`/`sections` are recomputed from the same query below. + const deriveRowKeys = useCallback( + (query: string): string[] => [ + ...rankProjectOptions(options, query, recentIds).map((match) => match.option.id), + ...(onAddProject ? [ADD_PROJECT_KEY] : []) + ], + [onAddProject, options, recentIds] ) - const filteredOptions = useMemo( - () => searchNewWorkspaceProjectOptions(options, query), - [options, query] + const { + query, + setQuery, + open, + setOpen, + close, + handleOpenChange, + armedKey, + arm, + moveArm, + inputRef, + listId, + setListNode + } = useTypeAheadCombobox(deriveRowKeys) + + const matches = useMemo( + () => rankProjectOptions(options, query, recentIds), + [options, query, recentIds] ) - - const cancelFocusFrame = useCallback((): void => { - if (focusFrameRef.current !== null) { - cancelAnimationFrame(focusFrameRef.current) - focusFrameRef.current = null - } - }, []) - - const setInputNode = useCallback( - (node: HTMLInputElement | null): void => { - if (node === null) { - cancelFocusFrame() - } - inputRef.current = node - }, - [cancelFocusFrame] + const sections = useMemo( + () => sectionProjectOptions(matches, query, recentIds), + [matches, query, recentIds] ) + const ambiguous = useMemo(() => getAmbiguousProjectOptionIds(options), [options]) + const selected = options.find((option) => option.id === value) ?? null + // A committed pick shows as the field's own content; typing replaces it. + const committed = selected !== null && query.length === 0 - const focusSearchInput = useCallback((): void => { - cancelFocusFrame() - focusFrameRef.current = requestAnimationFrame(() => { - focusFrameRef.current = null - inputRef.current?.focus() - }) - }, [cancelFocusFrame]) - - const handleOpenChange = useCallback( - (nextOpen: boolean): void => { - setOpen(nextOpen) - if (nextOpen) { - setCommandValue(value ?? '') + const commit = useCallback( + (key: string | null): void => { + if (key === null) { return } - cancelFocusFrame() - setQuery('') - }, - [cancelFocusFrame, value] - ) - - const handleSelect = useCallback( - (projectId: string): void => { - onValueChange(projectId) - setOpen(false) - setQuery('') - onValueSelected?.(projectId) - }, - [onValueChange, onValueSelected] - ) - - const handleAddProject = useCallback((): void => { - setOpen(false) - setQuery('') - onAddProject?.() - }, [onAddProject]) - - const handleTriggerKeyDown = useCallback( - (event: React.KeyboardEvent): void => { - if (open) { + close() + if (key === ADD_PROJECT_KEY) { + onAddProject?.() return } + onValueChange(key) + onValueSelected?.(key) + }, + [close, onAddProject, onValueChange, onValueSelected] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() - setCommandValue(value ?? '') setOpen(true) + moveArm(event.key === 'ArrowDown' ? 1 : -1) return } - if (event.metaKey || event.ctrlKey || event.altKey) { - return - } - if (event.key.length === 1 && /\S/.test(event.key)) { + if (event.key === 'Enter' && open) { event.preventDefault() - setCommandValue(value ?? '') - setQuery(event.key) + commit(armedKey) + return + } + // Why: not gated on `open` — a leftover query with the list closed would + // otherwise strand the field showing text that matches nothing and hides + // the committed project. Escape always restores the committed display, + // and only bubbles (to close the dialog) when there's nothing to undo. + if (event.key === 'Escape' && (open || query.length > 0)) { + event.preventDefault() + event.stopPropagation() + close() + return + } + // Backspace on a committed pick unsticks it back into editable text. + if (event.key === 'Backspace' && committed && selected) { + event.preventDefault() + setQuery(selected.displayName) setOpen(true) } }, - [open, value] + [armedKey, close, commit, committed, moveArm, open, query, selected, setOpen, setQuery] ) return ( - - - + + {committed && selected ? : null} + +
+ { + setQuery(event.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + className={cn( + 'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground', + committed && 'text-transparent caret-foreground' + )} + /> + {/* Painted over the input so a long name and its path can shrink at + different rates instead of truncating as one flat string. */} + {committed && selected ? ( + + ) : null} +
+ +
+ { - event.preventDefault() - focusSearchInput() + sideOffset={4} + // Why opaque + no fade: this popover lands directly on the composer + // dialog, not the app canvas. The shared surface is translucent and + // fades 0→1, so mid-animation the Name field underneath reads straight + // through the list — two layers at once, which is the "double flash". + // An opaque surface that zooms without fading resolves it. (`bg-popover` + // alone loses to the primitive's arbitrary-value background, hence the + // matching arbitrary form.) Every other caller keeps the blur and fade. + className={cn( + 'flex w-[var(--radix-popover-trigger-width)] min-w-[17rem] flex-col p-0', + COMBOBOX_POPOVER_SURFACE + )} + // Focus stays in the field — it's the search box — so the popover must + // not steal it on open, nor yank it back on close after a pick. + onOpenAutoFocus={(event) => event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // Why: the field lives in the anchor, not inside the content, so Radix + // sees a focus/pointer event "outside" the layer and dismisses it the + // instant you tab in. Keep the layer open whenever the interaction is + // within this control; genuine outside events still close it. + onFocusOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + onInteractOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } }} > - - - - - {translate( - 'auto.components.new.workspace.ProjectCombobox.empty', - 'No projects match your search.' - )} - - {filteredOptions.map((option) => ( - handleSelect(option.id)} - className="items-center gap-2 px-3 py-1.5" - > - -
- {option.kind === 'project-group' ? ( -
- - {option.displayName} -
- ) : ( - - )} -

- {option.detail} -

-
-
+ {/* The listbox wraps a scrolling pane plus the pinned Add row, so both + stay `option` children of one listbox. */} +
+ {/* Why: `presentation` — this element exists to scroll, and an + unroled div between a listbox and its options breaks the + ownership relationship assistive tech relies on. */} +
+ {matches.length === 0 ? ( + // Why: row-height rather than a tall centred block — a 60px panel + // next to 32px rows reads as a different kind of surface and + // makes an empty result feel like an error. +

+ {options.length === 0 + ? translate( + 'auto.components.new.workspace.ProjectCombobox.noProjects', + 'No projects yet.' + ) + : translate( + 'auto.components.new.workspace.ProjectCombobox.empty', + 'No projects match your search.' + )} +

+ ) : null} + {sections.map((section) => ( + // Why: `role="group"` — a bare div between a listbox and its + // options breaks the ownership relationship for screen readers, + // which is the only thing that makes the headings announceable. +
+ {section.heading ? ( + + ) : null} + {section.items.map((scored) => ( + arm(scored.option.id)} + onCommit={() => commit(scored.option.id)} + /> + ))} +
))} - +
{onAddProject ? ( - // Why: pinned below the scrollable list so "Add a new project" stays - // reachable in every state, including when the list is empty and - // CommandEmpty shows "No projects match your search." -
- +
event.preventDefault()} + onMouseMove={() => arm(ADD_PROJECT_KEY)} + onClick={() => commit(ADD_PROJECT_KEY)} + className={cn( + 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', + armedKey === ADD_PROJECT_KEY && 'bg-accent text-accent-foreground' + )} + > + + + {translate( + 'auto.components.new.workspace.ProjectCombobox.addProject', + 'Add a new project' + )} +
) : null} - +
) diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx new file mode 100644 index 00000000000..e031bed5cf2 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { FolderOpen } from 'lucide-react' +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import { splitDetailForElision } from './project-combobox-matching' + +/** Identity mark shared by the field and every row, so a project reads the same in both. */ +export function ProjectOptionMark({ + option +}: { + option: NewWorkspaceProjectOption +}): React.JSX.Element { + return option.kind === 'project-group' ? ( + + ) : ( + // Square, matching the mark everywhere else (jump palette, sidebar). + + ) +} + +/** Underlines the matched characters so a fuzzy hit is legible, not mysterious. */ +export function MatchedText({ + text, + hits, + className +}: { + text: string + hits: readonly number[] + className?: string +}): React.JSX.Element { + const marks = new Set(hits) + if (marks.size === 0) { + return {text} + } + return ( + + {[...text].map((char, index) => + marks.has(index) ? ( + + {char} + + ) : ( + char + ) + )} + + ) +} + +/** + * Detail line that keeps its final two path segments when space runs out — + * `~/dev/…/services/checkout-api` stays distinct from its `-web` sibling, where + * a plain truncate would render both identically. + */ +export function ProjectOptionDetail({ + detail, + hits, + className +}: { + detail: string + hits?: readonly number[] + className?: string +}): React.JSX.Element { + const split = splitDetailForElision(detail) + if (!split) { + return ( + + {hits ? : detail} + + ) + } + return ( + + {/* Head collapses first; the tail only truncates once the head is gone. */} + {split.head} + /{split.tail} + + ) +} + +export function ProjectOptionRow({ + option, + nameHits, + detailHits, + armed, + current, + ambiguous, + optionId, + onArm, + onCommit +}: { + option: NewWorkspaceProjectOption + nameHits: readonly number[] + detailHits: readonly number[] + armed: boolean + current: boolean + ambiguous: boolean + optionId: string | undefined + onArm: () => void + onCommit: () => void +}): React.JSX.Element { + return ( +
event.preventDefault()} + onMouseMove={onArm} + onClick={onCommit} + className={cn( + // Why: `items-baseline` (not `items-center`) — the name and its smaller + // detail line sit on one baseline, so the two type sizes read as one + // line rather than two boxes centred against each other. + 'flex h-8 cursor-default items-baseline gap-2 rounded-sm px-2 text-sm', + armed && 'bg-accent text-accent-foreground', + current && !armed && 'bg-accent/60' + )} + > + {/* The mark is a dot, not text, so it centres on the row itself. */} + + + + {/* Name keeps up to half the row; the path absorbs the rest so a deep + path can't squeeze the name down to "chec…". */} + + +
+ ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx new file mode 100644 index 00000000000..4b3d0a07ea9 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetCombobox.tsx @@ -0,0 +1,380 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { Popover, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { isWithinComboboxRoot, useTypeAheadCombobox } from './use-type-ahead-combobox' +import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' +import { + ConnectHostButton, + HostRowIcon, + NeedsSetupHostIcon, + RunTargetRow +} from './RunTargetComboboxRow' +import { + buildRunTargetRows, + getEphemeralVmLabel, + getRecipeDetail, + RUN_TARGET_ADD_HOST_KEY, + type EphemeralVmRecipeOption +} from './run-target-options' +import { AddHostSubmenuRow, RecipesSubmenuRow } from './RunTargetSubmenus' +import RunTargetField from './RunTargetField' + +type RunTargetComboboxProps = { + hostOptions: readonly ProjectHostSetupOption[] + hostValue: string | null + onHostChange?: (setupId: string) => void + recipes: EphemeralVmRecipeOption[] + recipeValue: string | null + onRecipeChange?: (recipeId: string | null) => void + onAddRemoteServer?: () => void + onAddSshHost?: () => void + onConnectHost?: (option: NeedsSetupProjectHostOption) => Promise | void +} + +const ROOT_ATTRIBUTE = 'data-run-target-combobox-root' + +/** + * Run-target picker, built to match the project picker: the field *is* the + * search, exactly one row is armed and Enter takes it, hovering arms, and the + * "Add host" row is pinned to the popover edge so it survives every state. + * + * Two things the project picker doesn't have: disconnected hosts carry an + * inline Connect action that must not select the row, and two rows open nested + * lists (VM recipes, Add host) rather than committing. + */ +export default function RunTargetCombobox({ + hostOptions, + hostValue, + onHostChange, + recipes, + recipeValue, + onRecipeChange, + onAddRemoteServer, + onAddSshHost, + onConnectHost +}: RunTargetComboboxProps): React.JSX.Element { + const [submenu, setSubmenu] = useState<'recipes' | 'add-host' | null>(null) + // Track in-flight connects per host so one stalling connect never blocks the others. + const [connectingHostIds, setConnectingHostIds] = useState>(() => new Set()) + + const hasAddHost = Boolean(onAddSshHost || onAddRemoteServer) + const deriveRowKeys = useCallback( + (query: string): string[] => + buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }).rows.map((row) => row.key), + [hasAddHost, hostOptions, recipes] + ) + const combobox = useTypeAheadCombobox(deriveRowKeys) + const { query, setQuery, open, setOpen, armedKey, arm, moveArm, inputRef, listId, setListNode } = + combobox + + const { rows, matchedRecipes } = useMemo( + () => buildRunTargetRows({ hostOptions, recipes, query, hasAddHost }), + [hasAddHost, hostOptions, query, recipes] + ) + const readyHostOptions = useMemo( + () => hostOptions.filter((option) => option.kind === 'ready'), + [hostOptions] + ) + const selectedHost = + readyHostOptions.find((option) => option.id === hostValue) ?? readyHostOptions[0] ?? null + const selectedRecipe = recipes.find((recipe) => recipe.id === recipeValue) ?? null + const armedRow = rows.find((row) => row.key === armedKey) ?? rows[0] ?? null + // Only a committed selection paints the field; typing replaces it. + const committed = query.length === 0 && (selectedRecipe !== null || selectedHost !== null) + + // Closing also drops any open submenu, which the shared hook doesn't know about. + const close = useCallback((): void => { + combobox.close() + setSubmenu(null) + }, [combobox]) + + const selectHost = useCallback( + (setupId: string): void => { + onHostChange?.(setupId) + onRecipeChange?.(null) + close() + }, + [close, onHostChange, onRecipeChange] + ) + + const selectRecipe = useCallback( + (recipeId: string): void => { + onRecipeChange?.(recipeId) + close() + }, + [close, onRecipeChange] + ) + + const connectHost = useCallback( + async (option: NeedsSetupProjectHostOption): Promise => { + if (!option.connectAction || !onConnectHost || connectingHostIds.has(option.hostId)) { + return + } + setConnectingHostIds((current) => new Set(current).add(option.hostId)) + try { + await onConnectHost(option) + } finally { + // Always clear when the connect settles (success, failure, or timeout) + // so the row's spinner stops. + setConnectingHostIds((current) => { + if (!current.has(option.hostId)) { + return current + } + const next = new Set(current) + next.delete(option.hostId) + return next + }) + } + }, + [connectingHostIds, onConnectHost] + ) + + /** Commits a row, or opens its submenu when the row is a submenu row. */ + const activate = useCallback( + (key: string | null): void => { + const row = rows.find((candidate) => candidate.key === key) + if (!row) { + return + } + if (row.kind === 'ready') { + selectHost(row.option.id) + return + } + if (row.kind === 'needs-setup') { + // Not ready: selecting is a no-op, the Connect action is the way forward. + return + } + setSubmenu(row.kind === 'recipes' ? 'recipes' : 'add-host') + }, + [rows, selectHost] + ) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setOpen(true) + moveArm(event.key === 'ArrowDown' ? 1 : -1) + setSubmenu(null) + return + } + if ((event.key === 'Enter' || event.key === 'ArrowRight') && open) { + event.preventDefault() + activate(armedRow?.key ?? null) + return + } + if (event.key === 'Escape' && (open || query.length > 0)) { + event.preventDefault() + event.stopPropagation() + // A submenu closes first, so Escape backs out one layer at a time. + if (submenu !== null) { + setSubmenu(null) + return + } + close() + } + }, + [activate, armedRow, close, moveArm, open, query, setOpen, submenu] + ) + + const handleOpenChange = useCallback( + (next: boolean): void => { + if (next) { + setOpen(true) + return + } + close() + }, + [close, setOpen] + ) + + const fieldLabel = selectedRecipe + ? `${getEphemeralVmLabel()} / ${selectedRecipe.name}` + : (selectedHost?.label ?? '') + const fieldDetail = selectedRecipe ? getRecipeDetail(selectedRecipe) : (selectedHost?.path ?? '') + + return ( + + { + setQuery(value) + setOpen(true) + setSubmenu(null) + }} + open={open} + onOpenRequest={() => setOpen(true)} + onToggle={() => setOpen(!open)} + committed={committed} + isRecipe={selectedRecipe !== null} + hostId={selectedHost?.hostId ?? null} + label={fieldLabel} + detail={fieldDetail} + listId={listId} + hasArmedRow={armedRow !== null} + inputRef={inputRef} + onKeyDown={handleKeyDown} + /> + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // The field lives in the anchor, not the content, so Radix would see a + // focus/pointer event "outside" and dismiss the instant you tab in. + onFocusOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + onInteractOutside={(event) => { + if (isWithinComboboxRoot(event.target, ROOT_ATTRIBUTE)) { + event.preventDefault() + } + }} + > +
+
+ {rows.filter((row) => row.kind !== 'add-host').length === 0 ? ( +

+ {translate( + 'auto.components.NewWorkspaceComposerCard.noRunTargets', + 'No run targets are ready for this project.' + )} +

+ ) : null} + {rows.map((row) => { + if (row.kind === 'add-host') { + return null + } + const isArmed = armedRow?.key === row.key + const optionId = isArmed ? `${listId}-armed` : undefined + if (row.kind === 'ready') { + return ( + } + label={row.option.label} + detail={row.option.path} + armed={isArmed} + current={selectedRecipe === null && row.option.id === selectedHost?.id} + optionId={optionId} + onArm={() => { + arm(row.key) + setSubmenu(null) + }} + onCommit={() => selectHost(row.option.id)} + /> + ) + } + if (row.kind === 'needs-setup') { + const connecting = connectingHostIds.has(row.option.hostId) + const hasConnect = Boolean(row.option.connectAction && onConnectHost) + return ( + + } + label={row.option.label} + // Why: a Connect button on the row already says the host + // isn't connected, so its detail line only repeats that. + // Rows without the action still need theirs to explain why. + detail={hasConnect ? '' : row.option.detail} + armed={isArmed} + current={false} + dimmed + optionId={optionId} + onArm={() => { + arm(row.key) + setSubmenu(null) + }} + onCommit={() => {}} + trailing={ + row.option.connectAction && onConnectHost ? ( + void connectHost(row.option)} + /> + ) : undefined + } + /> + ) + } + // Recipes submenu row. + return ( + setSubmenu(next ? 'recipes' : null)} + armed={isArmed} + optionId={optionId} + recipes={matchedRecipes} + selectedRecipeId={selectedRecipe?.id ?? null} + onArm={() => { + arm(row.key) + setSubmenu('recipes') + }} + onSelectRecipe={selectRecipe} + /> + ) + })} +
+ {hasAddHost ? ( + setSubmenu(next ? 'add-host' : null)} + armed={armedRow?.key === RUN_TARGET_ADD_HOST_KEY} + optionId={armedRow?.key === RUN_TARGET_ADD_HOST_KEY ? `${listId}-armed` : undefined} + onArm={() => { + arm(RUN_TARGET_ADD_HOST_KEY) + setSubmenu('add-host') + }} + {...(onAddSshHost + ? { + onAddSshHost: () => { + close() + onAddSshHost() + } + } + : {})} + {...(onAddRemoteServer + ? { + onAddRemoteServer: () => { + close() + onAddRemoteServer() + } + } + : {})} + /> + ) : null} +
+
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx new file mode 100644 index 00000000000..fb5fa811f07 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetComboboxRow.tsx @@ -0,0 +1,175 @@ +import React from 'react' +import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' +import { ProjectOptionDetail } from './ProjectComboboxRow' +import { translate } from '@/i18n/i18n' + +/** The local machine isn't a server — a monitor glyph reads as "this computer". */ +export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element { + const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server + return +} + +/** + * One run-target row. Shares the Project picker's shape — 32px, label and + * right-aligned detail on one baseline — so the two composer fields read as one + * control. `stacked` switches to a two-line card for the Add-host choices, + * where the description explains what you're picking rather than labelling a + * thing you already know. + */ +export function RunTargetRow({ + icon, + label, + detail, + armed, + current, + optionId, + dimmed = false, + submenu = false, + stacked = false, + onArm, + onCommit, + trailing +}: { + icon: React.ReactNode + label: string + detail: string + armed: boolean + current: boolean + optionId: string | undefined + /** Not-ready hosts are dormant, not errors — quiet them without disabling. */ + dimmed?: boolean + /** Opens a nested list, so it gets a trailing chevron. */ + submenu?: boolean + /** Two-line card: label over description, for rows that need explaining. */ + stacked?: boolean + onArm: () => void + onCommit: () => void + trailing?: React.ReactNode +}): React.JSX.Element { + return ( +
event.preventDefault()} + onMouseMove={onArm} + onClick={onCommit} + className={cn( + 'flex cursor-default gap-2 rounded-sm px-2 text-sm', + stacked ? 'items-center py-1.5' : 'h-8 items-baseline', + armed && 'bg-accent text-accent-foreground', + current && !armed && 'bg-accent/60' + )} + > + {/* Icons are glyphs, not text, so they centre on the row. */} + + {icon} + + {stacked ? ( + + {label} + {detail} + + ) : ( + <> + + {label} + + + + )} + {trailing} + {submenu ? ( + + + + ) : null} +
+ ) +} + +/** Glyph for a host that can't run yet: connecting, broken, or merely dormant. */ +export function NeedsSetupHostIcon({ + hostId, + connecting, + attention +}: { + hostId: ExecutionHostId + connecting: boolean + attention: boolean +}): React.JSX.Element { + if (connecting) { + return + } + if (attention) { + return + } + return +} + +/** Inline Connect action on a disconnected host row. */ +export function ConnectHostButton({ + connecting, + onConnect +}: { + connecting: boolean + onConnect: () => void +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetField.tsx b/src/renderer/src/components/new-workspace/RunTargetField.tsx new file mode 100644 index 00000000000..4694eae3070 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetField.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import { ChevronDown, Cloud } from 'lucide-react' +import { PopoverAnchor } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { HostRowIcon } from './RunTargetComboboxRow' +import { COMBOBOX_FIELD_SHELL } from './type-ahead-combobox-styles' + +/** + * The run-target field: a text input that *is* the search box, painted over + * with the committed selection so a long label and its path shrink at + * different rates instead of truncating as one flat string. + */ +export default function RunTargetField({ + query, + onQueryChange, + open, + onOpenRequest, + onToggle, + committed, + isRecipe, + hostId, + label, + detail, + listId, + hasArmedRow, + inputRef, + onKeyDown +}: { + query: string + onQueryChange: (value: string) => void + open: boolean + onOpenRequest: () => void + onToggle: () => void + committed: boolean + isRecipe: boolean + hostId: ExecutionHostId | null + label: string + detail: string + listId: string + hasArmedRow: boolean + inputRef: React.RefObject + onKeyDown: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + return ( + +
{ + inputRef.current?.focus() + onOpenRequest() + }} + className={COMBOBOX_FIELD_SHELL} + > + + {committed ? ( + isRecipe ? ( + + ) : hostId ? ( + + ) : null + ) : null} + +
+ onQueryChange(event.target.value)} + onFocus={onOpenRequest} + onKeyDown={onKeyDown} + className={cn( + 'w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground', + committed && 'text-transparent caret-foreground' + )} + /> + {committed ? ( + + ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx new file mode 100644 index 00000000000..ebb41f0c462 --- /dev/null +++ b/src/renderer/src/components/new-workspace/RunTargetSubmenus.tsx @@ -0,0 +1,192 @@ +import React from 'react' +import { ChevronDown, Cloud, Plus, Server } from 'lucide-react' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { RunTargetRow } from './RunTargetComboboxRow' +import { + getEphemeralVmLabel, + getRecipeDetail, + RUN_TARGET_ADD_HOST_KEY, + type EphemeralVmRecipeOption +} from './run-target-options' +import { COMBOBOX_POPOVER_SURFACE } from './type-ahead-combobox-styles' + +const SUBMENU_CONTENT = cn('w-72 p-1', COMBOBOX_POPOVER_SURFACE) + +/** The "Per-Workspace Environment" row and its nested recipe list. */ +export function RecipesSubmenuRow({ + open, + onOpenChange, + armed, + optionId, + recipes, + selectedRecipeId, + onArm, + onSelectRecipe +}: { + open: boolean + onOpenChange: (open: boolean) => void + armed: boolean + optionId: string | undefined + recipes: readonly EphemeralVmRecipeOption[] + selectedRecipeId: string | null + onArm: () => void + onSelectRecipe: (recipeId: string) => void +}): React.JSX.Element { + const [hoveredKey, setHoveredKey] = React.useState(null) + return ( + + +
+ } + label={getEphemeralVmLabel()} + detail={translate( + 'auto.components.NewWorkspaceComposerCard.perWorkspaceEnvHint', + 'Provision an on-demand environment from a recipe' + )} + armed={armed} + current={selectedRecipeId !== null} + optionId={optionId} + submenu + onArm={onArm} + onCommit={() => onOpenChange(true)} + /> +
+
+ event.preventDefault()} + > + {/* Why: submenu rows track their own hover — without it they were the + only rows in either picker that never highlighted under the pointer. */} +
setHoveredKey(null)} + > + {recipes.map((recipe) => ( + } + label={recipe.name} + detail={getRecipeDetail(recipe)} + armed={hoveredKey === recipe.id} + current={recipe.id === selectedRecipeId} + optionId={undefined} + onArm={() => setHoveredKey(recipe.id)} + onCommit={() => onSelectRecipe(recipe.id)} + /> + ))} +
+
+
+ ) +} + +/** + * "Add host", pinned to the popover edge so it stays reachable in every state — + * scrolled, filtered to nothing, or with no hosts at all. + */ +export function AddHostSubmenuRow({ + open, + onOpenChange, + armed, + optionId, + onArm, + onAddSshHost, + onAddRemoteServer +}: { + open: boolean + onOpenChange: (open: boolean) => void + armed: boolean + optionId: string | undefined + onArm: () => void + onAddSshHost?: () => void + onAddRemoteServer?: () => void +}): React.JSX.Element { + const [hoveredKey, setHoveredKey] = React.useState(null) + const addHostLabel = translate('auto.components.NewWorkspaceComposerCard.addHost', 'Add host') + return ( + + +
event.preventDefault()} + onMouseMove={onArm} + onClick={() => onOpenChange(true)} + className={cn( + 'flex h-9 shrink-0 cursor-default items-center gap-2 border-t border-border px-2 text-sm', + armed && 'bg-accent text-accent-foreground' + )} + > + + {addHostLabel} + + + +
+
+ event.preventDefault()} + > +
setHoveredKey(null)}> + {onAddSshHost ? ( + } + label={translate( + 'auto.components.NewWorkspaceComposerCard.addSshHost', + 'Add SSH host' + )} + detail={translate( + 'auto.components.NewWorkspaceComposerCard.addSshHostHint', + 'Use an existing machine over SSH' + )} + armed={hoveredKey === 'ssh'} + current={false} + stacked + optionId={undefined} + onArm={() => setHoveredKey('ssh')} + onCommit={onAddSshHost} + /> + ) : null} + {onAddRemoteServer ? ( + } + label={translate( + 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServer', + 'Add Remote Orca Server' + )} + detail={translate( + 'auto.components.NewWorkspaceComposerCard.addRemoteOrcaServerHint', + 'Pair another Orca runtime' + )} + armed={hoveredKey === 'remote'} + current={false} + stacked + optionId={undefined} + onArm={() => setHoveredKey('remote')} + onCommit={onAddRemoteServer} + /> + ) : null} +
+
+
+ ) +} + +export { RUN_TARGET_ADD_HOST_KEY } diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts new file mode 100644 index 00000000000..6e16dae386d --- /dev/null +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import { + getAmbiguousProjectOptionIds, + rankProjectOptions, + sectionProjectOptions, + splitDetailForElision +} from './project-combobox-matching' + +function project(id: string, displayName: string, detail: string): NewWorkspaceProjectOption { + return { kind: 'project', id, projectId: id, displayName, badgeColor: '#111', detail } +} + +const orca = project('orca', 'orca', 'stablyai/orca') +const relay = project('relay', 'orca-relay', 'stablyai/orca-relay') +const gateway = project('gateway', 'api-gateway', 'acme/api-gateway') + +describe('rankProjectOptions', () => { + it('ranks a name-prefix match above a mid-name match', () => { + const ranked = rankProjectOptions([relay, orca], 'orca', []) + expect(ranked[0]?.option.id).toBe('orca') + }) + + it('matches the detail line when the name does not match', () => { + const ranked = rankProjectOptions([gateway], 'acme', []) + expect(ranked).toHaveLength(1) + expect(ranked[0]?.detailHits.length).toBeGreaterThan(0) + }) + + it('matches a scattered subsequence in the name but not in the detail', () => { + const hosts = project('infra', 'infra', '3 hosts configured') + // "sc" appears scattered in "hosts configured" but detail matching is + // substring-only, so this must not match on the detail line. + expect(rankProjectOptions([hosts], 'sc', [])).toHaveLength(0) + expect(rankProjectOptions([hosts], 'ifa', [])).toHaveLength(1) + }) + + it('orders an unfiltered list by recency', () => { + const ranked = rankProjectOptions([orca, relay, gateway], '', ['gateway', 'relay']) + expect(ranked.map((r) => r.option.id)).toEqual(['gateway', 'relay', 'orca']) + }) + + it('returns nothing for an oversized query rather than scanning it', () => { + expect(rankProjectOptions([orca], 'x'.repeat(4096), [])).toEqual([]) + }) +}) + +describe('sectionProjectOptions', () => { + const many = Array.from({ length: 7 }, (_, i) => project(`p${i}`, `proj-${i}`, `acme/proj-${i}`)) + + it('collapses to a single unlabelled list while a query is live', () => { + const matches = rankProjectOptions(many, 'proj', []) + const sections = sectionProjectOptions(matches, 'proj', ['p3']) + expect(sections).toHaveLength(1) + expect(sections[0]?.heading).toBeNull() + }) + + it('splits into Recent/Projects once the list is long enough to warrant it', () => { + const matches = rankProjectOptions(many, '', ['p3', 'p5']) + const sections = sectionProjectOptions(matches, '', ['p3', 'p5']) + expect(sections.map((s) => s.heading)).toEqual(['Recent', 'Projects']) + expect(sections[0]?.items.map((i) => i.option.id)).toEqual(['p3', 'p5']) + }) + + it('keeps a short list unsectioned', () => { + const matches = rankProjectOptions([orca, relay], '', ['relay']) + expect(sectionProjectOptions(matches, '', ['relay'])).toHaveLength(1) + }) +}) + +describe('getAmbiguousProjectOptionIds', () => { + it('flags only ids whose display name repeats', () => { + const a = project('a', 'scratch', '~/code/scratch') + const b = project('b', 'scratch', '~/src/scratch') + const ids = getAmbiguousProjectOptionIds([a, b, orca]) + expect(ids).toEqual(new Set(['a', 'b'])) + }) +}) + +describe('splitDetailForElision', () => { + it('keeps the last two segments so sibling paths stay distinguishable', () => { + const split = splitDetailForElision('~/Developer/work/acme/monorepo/services/checkout-api') + expect(split?.tail).toBe('services/checkout-api') + }) + + it('leaves short or shallow details alone', () => { + expect(splitDetailForElision('stablyai/orca')).toBeNull() + expect(splitDetailForElision('3 hosts configured')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts new file mode 100644 index 00000000000..edbd5595e70 --- /dev/null +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts @@ -0,0 +1,156 @@ +import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' +import { isNewWorkspaceProjectOptionQueryTooLarge } from '@/lib/new-workspace-project-options' + +export type ScoredProjectOption = { + option: NewWorkspaceProjectOption + score: number + nameHits: readonly number[] + detailHits: readonly number[] +} + +function substringHits(text: string, query: string): number[] | null { + const at = text.toLowerCase().indexOf(query) + return at < 0 ? null : Array.from({ length: query.length }, (_, offset) => at + offset) +} + +/** + * Verbatim run, else scattered subsequence. Only names get the loose pass — + * a subsequence over a detail line matches "scr" against "3 hosts configured". + */ +function nameHitsFor(name: string, query: string): number[] | null { + const verbatim = substringHits(name, query) + if (verbatim) { + return verbatim + } + const haystack = name.toLowerCase() + const hits: number[] = [] + let cursor = 0 + for (const char of query) { + const found = haystack.indexOf(char, cursor) + if (found < 0) { + return null + } + hits.push(found) + cursor = found + 1 + } + return hits +} + +function nameScore(name: string, hits: readonly number[]): number { + const first = hits[0] ?? 0 + const contiguous = (hits.at(-1) ?? first) - first === hits.length - 1 + const boundary = first === 0 || /[^a-z0-9]/i.test(name[first - 1] ?? '') + const base = contiguous ? (first === 0 ? 900 : boundary ? 780 : 700) : 420 + return base - name.length * 0.4 +} + +/** + * Ranks options for a query, with recency breaking ties (and ordering the + * unfiltered list). `recentIds` is most-recent-first. + */ +export function rankProjectOptions( + options: readonly NewWorkspaceProjectOption[], + rawQuery: string, + recentIds: readonly string[] +): ScoredProjectOption[] { + if (isNewWorkspaceProjectOptionQueryTooLarge(rawQuery)) { + return [] + } + const query = rawQuery.trim().toLowerCase() + const scored: ScoredProjectOption[] = [] + for (const option of options) { + const recentAt = recentIds.indexOf(option.id) + const recency = recentAt < 0 ? 0 : 32 - recentAt * 4 + if (query.length === 0) { + scored.push({ option, score: recency, nameHits: [], detailHits: [] }) + continue + } + const nameHits = nameHitsFor(option.displayName, query) + const detailHits = substringHits(option.detail, query) + if (!nameHits && !detailHits) { + continue + } + scored.push({ + option, + score: (nameHits ? nameScore(option.displayName, nameHits) : 260) + recency, + nameHits: nameHits ?? [], + detailHits: detailHits ?? [] + }) + } + return scored.sort( + (a, b) => + b.score - a.score || + a.option.displayName.localeCompare(b.option.displayName) || + a.option.detail.localeCompare(b.option.detail) + ) +} + +export type ProjectOptionSection = { + key: 'recent' | 'projects' | 'folders' | 'results' + heading: string | null + items: ScoredProjectOption[] +} + +/** Below this a list is scannable, so sections are chrome rather than help. */ +const SECTION_THRESHOLD = 6 +const RECENT_LIMIT = 4 + +/** + * While a query is live the ranking *is* the order, so sections would fight it: + * everything collapses into one unlabelled result list. + */ +export function sectionProjectOptions( + matches: readonly ScoredProjectOption[], + query: string, + recentIds: readonly string[] +): ProjectOptionSection[] { + if (query.trim() !== '' || matches.length < SECTION_THRESHOLD) { + return [{ key: 'results', heading: null, items: [...matches] }] + } + const recentSet = new Set( + recentIds.filter((id) => matches.some((m) => m.option.id === id)).slice(0, RECENT_LIMIT) + ) + const recent = recentIds.flatMap((id) => + recentSet.has(id) ? matches.filter((m) => m.option.id === id) : [] + ) + const sections: ProjectOptionSection[] = [ + { key: 'recent', heading: 'Recent', items: recent }, + { + key: 'projects', + heading: 'Projects', + items: matches.filter((m) => m.option.kind === 'project' && !recentSet.has(m.option.id)) + }, + { + key: 'folders', + heading: 'Folders', + items: matches.filter((m) => m.option.kind === 'project-group') + } + ] + return sections.filter((section) => section.items.length > 0) +} + +/** Ids whose displayName repeats — those rows can't be read by name alone. */ +export function getAmbiguousProjectOptionIds( + options: readonly NewWorkspaceProjectOption[] +): Set { + const counts = new Map() + for (const option of options) { + counts.set(option.displayName, (counts.get(option.displayName) ?? 0) + 1) + } + return new Set( + options.filter((o) => (counts.get(o.displayName) ?? 0) > 1).map((option) => option.id) + ) +} + +/** + * A deep path's identity lives in its tail (`…/services/checkout-api`), which is + * exactly what a plain truncate throws away — two sibling paths then render + * identically. Split so the head can elide while the tail keeps its width. + */ +export function splitDetailForElision(detail: string): { head: string; tail: string } | null { + const segments = detail.split('/') + if (segments.length <= 3 || detail.length <= 28) { + return null + } + return { head: segments.slice(0, -2).join('/'), tail: segments.slice(-2).join('/') } +} diff --git a/src/renderer/src/components/new-workspace/run-target-options.ts b/src/renderer/src/components/new-workspace/run-target-options.ts new file mode 100644 index 00000000000..a07a094cf80 --- /dev/null +++ b/src/renderer/src/components/new-workspace/run-target-options.ts @@ -0,0 +1,110 @@ +import type { OrcaHooks } from '../../../../shared/types' +import type { + NeedsSetupProjectHostOption, + ProjectHostSetupOption, + ReadyProjectHostSetupOption +} from '@/lib/project-host-setup-options' +import { translate } from '@/i18n/i18n' + +export type EphemeralVmRecipeOption = NonNullable[number] + +export const RUN_TARGET_ADD_HOST_KEY = 'add-host' +export const RUN_TARGET_RECIPES_KEY = 'per-workspace-env' + +/** A row in the run-target list. Hosts commit; the last two open a submenu. */ +export type RunTargetRowModel = + | { key: string; kind: 'ready'; option: ReadyProjectHostSetupOption } + | { key: string; kind: 'needs-setup'; option: NeedsSetupProjectHostOption } + | { key: typeof RUN_TARGET_RECIPES_KEY; kind: 'recipes' } + | { key: typeof RUN_TARGET_ADD_HOST_KEY; kind: 'add-host' } + +export function getEphemeralVmLabel(): string { + return translate( + 'auto.components.NewWorkspaceComposerCard.ephemeralVm', + 'Per-Workspace Environment' + ) +} + +export function getRecipeCommandDisplay(command: string): string { + const trimmed = command.trim() + const quoted = trimmed.match(/^"([^"]+)"/) ?? trimmed.match(/^'([^']+)'/) + return quoted?.[1] ?? trimmed.split(/\s+/)[0] ?? trimmed +} + +export function getRecipeDestroyLabel(recipe: EphemeralVmRecipeOption): string { + if (recipe.destroyDisabled) { + return translate('auto.components.NewWorkspaceComposerCard.destroyDisabled', 'destroy disabled') + } + if (recipe.destroy) { + return translate( + 'auto.components.NewWorkspaceComposerCard.destroyConfigured', + 'destroy configured' + ) + } + return translate('auto.components.NewWorkspaceComposerCard.noDestroyConfigured', 'no destroy') +} + +/** One line under a recipe name: what it runs and whether it tears down. */ +export function getRecipeDetail(recipe: EphemeralVmRecipeOption): string { + return `${getRecipeCommandDisplay(recipe.create)} · ${getRecipeDestroyLabel(recipe)}` +} + +function matches(haystack: string, query: string): boolean { + return haystack.toLowerCase().includes(query) +} + +/** + * Filters hosts and recipes by a typed query. The submenu rows survive filtering + * only while they still have something to show — "Add host" always survives, so + * it stays reachable in every state including no-matches. + */ +export function buildRunTargetRows({ + hostOptions, + recipes, + query, + hasAddHost +}: { + hostOptions: readonly ProjectHostSetupOption[] + recipes: readonly EphemeralVmRecipeOption[] + query: string + hasAddHost: boolean +}): { rows: RunTargetRowModel[]; matchedRecipes: EphemeralVmRecipeOption[] } { + const trimmed = query.trim().toLowerCase() + const hostMatches = (option: ProjectHostSetupOption): boolean => + trimmed === '' || + matches(option.label, trimmed) || + matches(option.detail, trimmed) || + (option.kind === 'ready' && matches(option.path, trimmed)) + + const ready = hostOptions.filter( + (option): option is ReadyProjectHostSetupOption => + option.kind === 'ready' && hostMatches(option) + ) + const needsSetup = hostOptions.filter( + (option): option is NeedsSetupProjectHostOption => + option.kind === 'needs-setup' && hostMatches(option) + ) + const matchedRecipes = recipes.filter( + (recipe) => + trimmed === '' || + matches(recipe.name, trimmed) || + matches(getEphemeralVmLabel(), trimmed) || + matches(recipe.description ?? '', trimmed) + ) + + const rows: RunTargetRowModel[] = [ + ...ready.map((option) => ({ key: `host:${option.id}`, kind: 'ready' as const, option })), + ...needsSetup.map((option) => ({ + key: `needs:${option.id}`, + kind: 'needs-setup' as const, + option + })) + ] + if (matchedRecipes.length > 0) { + rows.push({ key: RUN_TARGET_RECIPES_KEY, kind: 'recipes' }) + } + if (hasAddHost) { + rows.push({ key: RUN_TARGET_ADD_HOST_KEY, kind: 'add-host' }) + } + return { rows, matchedRecipes } +} diff --git a/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts b/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts new file mode 100644 index 00000000000..71a3122b175 --- /dev/null +++ b/src/renderer/src/components/new-workspace/type-ahead-combobox-styles.ts @@ -0,0 +1,16 @@ +/** + * Shared recipes for the composer's type-ahead pickers, so Project and Run on + * can't drift apart. + */ + +/** Input-shaped shell without being an `` — the field wraps a bare input. */ +export const COMBOBOX_FIELD_SHELL = + 'flex h-9 w-full min-w-0 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30' + +/** + * Opaque and unfaded, unlike the shared popover recipe. These land directly on + * the composer dialog, so a translucent fade shows the form underneath + * mid-animation and the open reads as a double flash. + */ +export const COMBOBOX_POPOVER_SURFACE = + 'bg-[var(--popover)] data-[state=closed]:fade-out-100 data-[state=open]:fade-in-100 dark:bg-[var(--popover)]' diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts new file mode 100644 index 00000000000..44fddd40d08 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { orderProjectIdsByRecency } from './use-recent-project-ids' + +function worktree(partial: Partial): Worktree { + return { + id: `${partial.repoId ?? 'repo'}::${partial.path ?? '/tmp/wt'}`, + repoId: partial.repoId ?? 'repo', + displayName: 'wt', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + path: '/tmp/wt', + branch: 'main', + ...partial + } as Worktree +} + +describe('orderProjectIdsByRecency', () => { + it('orders projects by their newest workspace, newest first', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha', createdAt: 10 }), + worktree({ projectId: 'beta', createdAt: 30 }), + worktree({ projectId: 'gamma', createdAt: 20 }) + ]) + expect(ids.filter((id) => !id.includes(':'))).toEqual(['beta', 'gamma', 'alpha']) + }) + + it('uses the newest workspace per project, not the oldest', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha', createdAt: 1 }), + worktree({ projectId: 'alpha', createdAt: 99 }), + worktree({ projectId: 'beta', createdAt: 50 }) + ]) + expect(ids[0]).toBe('alpha') + }) + + it('skips legacy repo-only workspaces that carry no project identity', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: undefined, createdAt: 99 }), + worktree({ projectId: 'beta', createdAt: 1 }) + ]) + expect(ids).toContain('beta') + expect(ids).not.toContain(undefined) + }) + + it('emits the folder-group option id too, so grouped targets resolve', () => { + const ids = orderProjectIdsByRecency([worktree({ projectId: 'alpha', createdAt: 5 })]) + expect(ids).toContain('project-group:alpha') + }) + + it('treats a missing createdAt as oldest rather than crashing', () => { + const ids = orderProjectIdsByRecency([ + worktree({ projectId: 'alpha' }), + worktree({ projectId: 'beta', createdAt: 5 }) + ]) + expect(ids[0]).toBe('beta') + }) +}) diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts new file mode 100644 index 00000000000..3c68485ac52 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react' +import type { Worktree } from '../../../../shared/types' +import { useAllWorktrees } from '@/store/selectors' +import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options' + +/** + * Most-recently-used project option ids, newest first, derived from when the + * user last created a workspace in each project. Orca stores no explicit + * per-project recency, and workspace creation is exactly the action this picker + * is about to repeat, so it's the honest proxy rather than a new store field. + */ +export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] { + const newestByProject = new Map() + for (const worktree of worktrees) { + const projectId = worktree.projectId + // Legacy repo-only workspaces have no projectId and can't be attributed. + if (projectId === undefined || projectId === '') { + continue + } + const createdAt = worktree.createdAt ?? 0 + const seen = newestByProject.get(projectId) + if (seen === undefined || createdAt > seen) { + newestByProject.set(projectId, createdAt) + } + } + return [...newestByProject.entries()] + .sort((a, b) => b[1] - a[1]) + .flatMap(([projectId]) => [ + projectId, + // Folder-group options carry a prefixed id, so both forms resolve. + `${NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX}${projectId}` + ]) +} + +export function useRecentProjectIds(): string[] { + const worktrees = useAllWorktrees() + return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees]) +} diff --git a/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts b/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts new file mode 100644 index 00000000000..2cf53d56614 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-type-ahead-combobox.ts @@ -0,0 +1,113 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react' +import { useWheelScrollable } from './use-wheel-scrollable' + +/** + * The machinery shared by the composer's type-ahead pickers (Project, Run on): + * the field is the search box, exactly one row is armed at a time, and Enter + * takes it. + * + * Two subtleties worth keeping: + * - Armed is tracked by row *key*, not index, so a list arriving late over SSH + * cannot slide a different row under a keypress the user already aimed. The + * query it was aimed at rides along, so a newer query drops a stale arm + * during render rather than needing a reset effect. + * - Closing without committing clears the query, or the field is left showing + * text that matches nothing while hiding the committed selection. + */ +export function useTypeAheadCombobox(deriveRowKeys: (query: string) => readonly string[]): { + query: string + setQuery: (value: string) => void + open: boolean + setOpen: (open: boolean) => void + /** Clears the query and closes — the "back out without committing" path. */ + close: () => void + /** Radix `onOpenChange`: closing always drops an uncommitted query. */ + handleOpenChange: (next: boolean) => void + /** Row keys for the current query, as returned by `deriveRowKeys`. */ + rowKeys: readonly string[] + armedKey: string | null + arm: (key: string) => void + /** Moves the arm by `step` rows, clamped to the ends. */ + moveArm: (step: number) => void + inputRef: React.RefObject + listId: string + /** Ref callback for the scroll pane; also restores wheel scrolling. */ + setListNode: (node: HTMLDivElement | null) => void +} { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [armed, setArmed] = useState<{ key: string; query: string } | null>(null) + const rowKeys = deriveRowKeys(query) + const inputRef = useRef(null) + // react-remove-scroll (Radix Dialog) cancels wheel events for this portaled + // pane, so the list has to scroll itself. + const { ref: listRef, setNode: setListNode } = useWheelScrollable() + const listId = React.useId() + + // A stale arm (aimed at an earlier query) falls back to the first row. + const armedKey = armed !== null && armed.query === query ? armed.key : null + const armedIndex = Math.max(armedKey === null ? -1 : rowKeys.indexOf(armedKey), 0) + const resolvedKey = rowKeys[armedIndex] ?? null + + React.useEffect(() => { + if (open) { + listRef.current?.querySelector('[data-armed="true"]')?.scrollIntoView({ block: 'nearest' }) + } + }, [listRef, open, armedIndex, rowKeys.length]) + + const arm = useCallback( + // `query` is read at call time; the closure refreshes each render. + (key: string) => setArmed({ key, query }), + [query] + ) + + const moveArm = useCallback( + (step: number): void => { + const next = rowKeys[Math.min(Math.max(armedIndex + step, 0), rowKeys.length - 1)] + if (next !== undefined) { + setArmed({ key: next, query }) + } + }, + [armedIndex, query, rowKeys] + ) + + const close = useCallback((): void => { + setOpen(false) + setQuery('') + }, []) + + const handleOpenChange = useCallback( + (next: boolean): void => { + if (next) { + setOpen(true) + return + } + close() + }, + [close] + ) + + return useMemo( + () => ({ + query, + setQuery, + open, + setOpen, + close, + handleOpenChange, + rowKeys, + armedKey: resolvedKey, + arm, + moveArm, + inputRef, + listId, + setListNode + }), + [arm, close, handleOpenChange, listId, moveArm, open, query, resolvedKey, rowKeys, setListNode] + ) +} + +/** True when an event target lies inside the control marked by `rootAttribute`. */ +export function isWithinComboboxRoot(target: EventTarget | null, rootAttribute: string): boolean { + return target instanceof Element && target.closest(`[${rootAttribute}="true"]`) !== null +} diff --git a/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts b/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts new file mode 100644 index 00000000000..c2c010e9283 --- /dev/null +++ b/src/renderer/src/components/new-workspace/use-wheel-scrollable.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' + +/** + * Restores mouse-wheel scrolling for a scroll container inside a portaled + * popover, and hands back the node. + * + * Radix Dialog applies react-remove-scroll, which calls preventDefault() on + * wheel events for elements outside the dialog's DOM tree — the scrollbar + * renders and drags fine, but the wheel does nothing. A non-passive listener on + * the container scrolls it manually instead. `ui/command`'s CommandList carries + * the same shim; this is the equivalent for a plain scroll pane. + */ +export function useWheelScrollable(): { + ref: MutableRefObject + setNode: (node: T | null) => void +} { + const ref = useRef(null) + const detachRef = useRef<(() => void) | null>(null) + + const setNode = useCallback((node: T | null): void => { + detachRef.current?.() + detachRef.current = null + ref.current = node + if (!node) { + return + } + const onWheel = (event: WheelEvent): void => { + if (node.scrollHeight <= node.clientHeight) { + return + } + const delta = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * node.clientHeight + : event.deltaY + const max = node.scrollHeight - node.clientHeight + const next = Math.max(0, Math.min(max, node.scrollTop + delta)) + if (next === node.scrollTop) { + // At an edge: let the event bubble so an ancestor can take the scroll. + return + } + event.preventDefault() + event.stopPropagation() + node.scrollTop = next + } + node.addEventListener('wheel', onWheel, { passive: false }) + detachRef.current = () => node.removeEventListener('wheel', onWheel) + }, []) + + useEffect(() => () => detachRef.current?.(), []) + + return { ref, setNode } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8af57bc0104..4e497d0f10a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11411,9 +11411,17 @@ "placeholder": "Choose host" }, "ProjectCombobox": { - "search": "Search projects...", "empty": "No projects match your search.", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d60f8704ecf..d3e0baf4ec3 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11388,9 +11388,17 @@ "placeholder": "Elegir host" }, "ProjectCombobox": { - "search": "Buscar proyectos...", "empty": "Ningún proyecto coincide con tu búsqueda.", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e625872dbf1..1911a5a1c2c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11388,9 +11388,17 @@ "placeholder": "Choose host" }, "ProjectCombobox": { - "search": "Search projects...", "empty": "No projects match your search.", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 184d1acebef..653c41c3268 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11388,9 +11388,17 @@ "placeholder": "호스트 선택" }, "ProjectCombobox": { - "search": "프로젝트 검색...", "empty": "검색과 일치하는 프로젝트가 없습니다.", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7f9a1ddfe43..4b567a771fa 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11388,9 +11388,17 @@ "placeholder": "选择主机" }, "ProjectCombobox": { - "search": "搜索项目...", "empty": "没有匹配搜索的项目。", - "addProject": "Add a new project" + "addProject": "Add a new project", + "label": "Project", + "browse": "Browse projects", + "listLabel": "Projects", + "noProjects": "No projects yet." + }, + "RunTargetCombobox": { + "listLabel": "Run targets", + "label": "Run on", + "browse": "Browse run targets" } } }, From 84b335f80cc6351b36f85c7e5ab3f4acf1494578 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:29:23 -0700 Subject: [PATCH 08/53] fix(workspaces): support emoji-only names --- src/main/ipc/worktree-logic.test.ts | 20 +++++++++++++++ src/main/ipc/worktree-logic.ts | 10 ++++++++ src/main/ipc/worktrees.test.ts | 29 +++++++++++++++++++++ tests/e2e/worktree.spec.ts | 39 +++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+) diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index d7ce72a06fd..952d97c8688 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -74,6 +74,21 @@ describe('sanitizeWorktreeName', () => { expect(sanitizeWorktreeName('feat: 中文 (v2)')).toBe('feat-中文-v2') }) + it('uses a git-safe fallback when a name contains only emoji', () => { + expect(sanitizeWorktreeName('🚀')).toBe('workspace') + expect(sanitizeWorktreeName('👩‍💻✨')).toBe('workspace') + expect(sanitizeWorktreeName('🇯🇵')).toBe('workspace') + expect(sanitizeWorktreeName('1️⃣')).toBe('1') + }) + + it('keeps readable text while removing emoji from branch and path names', () => { + expect(sanitizeWorktreeName('Ship it 🚀')).toBe('Ship-it') + }) + + it('does not treat arbitrary punctuation as a workspace name', () => { + expect(() => sanitizeWorktreeName('!!!')).toThrow('Invalid worktree name') + }) + it('throws for empty name', () => { expect(() => sanitizeWorktreeName('')).toThrow('Invalid worktree name') }) @@ -84,6 +99,11 @@ describe('sanitizeWorktreeName', () => { }) describe('sanitizeWorktreeDisplayName', () => { + it('preserves emoji in display names', () => { + expect(sanitizeWorktreeDisplayName(' Ship it 🚀 ')).toBe('Ship it 🚀') + expect(sanitizeWorktreeDisplayName('👩‍💻')).toBe('👩‍💻') + }) + it('keeps readable punctuation while collapsing unsafe controls and whitespace', () => { expect(sanitizeWorktreeDisplayName(' Fix: login / callback\n\tregression\u0000 ')).toBe( 'Fix: login / callback regression' diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index d040dec5d0f..6a37af457b7 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -37,6 +37,10 @@ export function sanitizeWorktreeName(input: string): string { .replace(/\.{2,}/g, '.') .replace(/^[.-]+|[.-]+$/g, '') + if (!sanitized && containsEmoji(input)) { + return 'workspace' + } + if (!sanitized || sanitized === '.' || sanitized === '..') { throw new Error('Invalid worktree name') } @@ -44,6 +48,12 @@ export function sanitizeWorktreeName(input: string): string { return sanitized } +function containsEmoji(input: string): boolean { + return /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u.test( + input + ) +} + export function sanitizeWorktreeDisplayName(input: string): string | undefined { const withoutControls = Array.from(input, (char) => { const code = char.charCodeAt(0) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 4879edcdb08..dacc1292558 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -896,6 +896,35 @@ describe('registerWorktreeHandlers', () => { }) }) + it('keeps an emoji-only display name while using safe branch and path names', async () => { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/workspace', + head: 'abc123', + branch: 'workspace', + isBare: false, + isMainWorktree: false + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: '🚀' + }) + + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/workspace', + 'workspace', + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/workspace', + expect.objectContaining({ displayName: '🚀' }) + ) + }) + it('uses a repo-specific worktree base path when creating local worktrees', async () => { store.getRepo.mockReturnValue({ id: 'repo-1', diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 3c980756e8e..14967d126b0 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -152,6 +152,45 @@ test.describe('Create Workspace', () => { } }) + test('creates an emoji-named worktree with a safe git branch', async ({ orcaPage }) => { + const workspaceName = '🚀🧪✨' + + try { + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + await expect(dialog).toBeVisible() + await expect(dialog.locator('[data-workspace-name-input="true"]')).toBeVisible() + + const nameInput = dialog.getByPlaceholder(/Type a name/i) + await nameInput.fill(workspaceName) + + const createButton = dialog.getByRole('button', { name: /Create (Workspace|Worktree)/i }) + await expect(createButton).toBeEnabled() + await createButton.click() + + await expect(dialog).toBeHidden({ timeout: 15_000 }) + await expect(orcaPage.getByRole('option', { name: new RegExp(workspaceName) })).toBeVisible({ + timeout: 10_000 + }) + + const branch = await orcaPage.evaluate((displayName) => { + const worktrees = Object.values(window.__store!.getState().worktreesByRepo).flat() + return worktrees.find((worktree) => worktree.displayName === displayName)?.branch ?? null + }, workspaceName) + expect(branch).not.toBeNull() + expect(branch).not.toMatch(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/u) + } finally { + await orcaPage + .evaluate(() => { + window.__store?.getState().closeModal() + }) + .catch(() => { + /* page may already be torn down */ + }) + } + }) + test('shows a failed workspace entry when worktree creation fails', async ({ orcaPage }) => { await orcaPage.evaluate(() => { const store = window.__store From 038fd7a50ccb8b4a2245d79315cb8ab4e15d6465 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:54 -0700 Subject: [PATCH 09/53] feat(workspaces): derive readable emoji identifiers --- package.json | 1 + pnpm-lock.yaml | 18 +++++++ src/main/ipc/worktree-logic.test.ts | 18 ++++--- src/main/ipc/worktree-logic.ts | 3 +- src/main/ipc/worktrees.test.ts | 10 ++-- .../src/lib/worktree-palette-search.test.ts | 17 +++++++ src/shared/emoji-shortcode-catalog.ts | 49 +++++++++++++++++++ tests/e2e/worktree.spec.ts | 3 +- 8 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 src/shared/emoji-shortcode-catalog.ts diff --git a/package.json b/package.json index e21ee6be8dd..921d0e59e94 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", + "emojibase-data": "17.0.0", "happy-dom": "^20.9.0", "html-to-image": "^1.11.13", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b1a656bd12..64dd0cb3440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) + emojibase-data: + specifier: 17.0.0 + version: 17.0.0(emojibase@17.0.0) happy-dom: specifier: ^20.9.0 version: 20.9.0 @@ -3986,6 +3989,15 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emojibase-data@17.0.0: + resolution: {integrity: sha512-Yvgb5AWoHViHV/gq1qr5ZAarcBip+B27/ZLRsUJkbgAEaLlZ/fof9g882LTpmEpyhBNEC0m2SEmItljHsTygjA==} + peerDependencies: + emojibase: '*' + + emojibase@17.0.0: + resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==} + engines: {node: '>=18.12.0'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -10031,6 +10043,12 @@ snapshots: emoji-regex@8.0.0: {} + emojibase-data@17.0.0(emojibase@17.0.0): + dependencies: + emojibase: 17.0.0 + + emojibase@17.0.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 952d97c8688..5ae35f651f6 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -74,15 +74,19 @@ describe('sanitizeWorktreeName', () => { expect(sanitizeWorktreeName('feat: 中文 (v2)')).toBe('feat-中文-v2') }) - it('uses a git-safe fallback when a name contains only emoji', () => { - expect(sanitizeWorktreeName('🚀')).toBe('workspace') - expect(sanitizeWorktreeName('👩‍💻✨')).toBe('workspace') - expect(sanitizeWorktreeName('🇯🇵')).toBe('workspace') - expect(sanitizeWorktreeName('1️⃣')).toBe('1') + it('uses readable git-safe shortcodes for known emoji', () => { + expect(sanitizeWorktreeName('🚀')).toBe('rocket') + expect(sanitizeWorktreeName('👩‍💻✨')).toBe('woman-technologist-sparkles') + expect(sanitizeWorktreeName('🇯🇵')).toBe('jp') + expect(sanitizeWorktreeName('1️⃣')).toBe('one') }) - it('keeps readable text while removing emoji from branch and path names', () => { - expect(sanitizeWorktreeName('Ship it 🚀')).toBe('Ship-it') + it('keeps readable text and emoji shortcodes in branch and path names', () => { + expect(sanitizeWorktreeName('Ship it 🚀')).toBe('Ship-it-rocket') + }) + + it('uses a git-safe fallback for emoji newer than the shortcode catalog', () => { + expect(sanitizeWorktreeName('\u{1fae9}')).toBe('workspace') }) it('does not treat arbitrary punctuation as a workspace name', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 6a37af457b7..a056d657f02 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -3,6 +3,7 @@ import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/typ import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' +import { replaceKnownEmojiWithShortcodes } from '../../shared/emoji-shortcode-catalog' import { getWslHome, parseWslPath } from '../wsl' type WorktreePathSettings = Pick @@ -25,7 +26,7 @@ export function sanitizeWorktreeName(input: string): string { // name workspaces in their own language. Git ref-format permits non-ASCII // bytes, and modern filesystems handle UTF-8 paths. Only strip characters // git or the filesystem actually rejects. - const sanitized = input + const sanitized = replaceKnownEmojiWithShortcodes(input) .trim() .replace(/[^\p{L}\p{N}._-]+/gu, '-') .replace(/-+/g, '-') diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index dacc1292558..b5295570a13 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -899,9 +899,9 @@ describe('registerWorktreeHandlers', () => { it('keeps an emoji-only display name while using safe branch and path names', async () => { listWorktreesMock.mockResolvedValue([ { - path: '/workspace/workspace', + path: '/workspace/rocket', head: 'abc123', - branch: 'workspace', + branch: 'rocket', isBare: false, isMainWorktree: false } @@ -914,13 +914,13 @@ describe('registerWorktreeHandlers', () => { expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', - '/workspace/workspace', - 'workspace', + '/workspace/rocket', + 'rocket', 'origin/main', false ) expect(store.setWorktreeMeta).toHaveBeenCalledWith( - 'repo-1::/workspace/workspace', + 'repo-1::/workspace/rocket', expect.objectContaining({ displayName: '🚀' }) ) }) diff --git a/src/renderer/src/lib/worktree-palette-search.test.ts b/src/renderer/src/lib/worktree-palette-search.test.ts index 7059ee4ad51..4cb69b75e77 100644 --- a/src/renderer/src/lib/worktree-palette-search.test.ts +++ b/src/renderer/src/lib/worktree-palette-search.test.ts @@ -86,6 +86,23 @@ describe('worktree-palette-search', () => { ]) }) + it('finds an emoji-named workspace by its readable branch shortcode', () => { + const results = searchWorktrees( + [makeWorktree({ displayName: '🚀', branch: 'refs/heads/rocket' })], + 'rocket', + repoMap, + null, + null + ) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + worktreeId: 'wt-1', + matchedField: 'branch', + branchRange: { start: 0, end: 6 } + }) + }) + it('rejects oversized pasted queries before reading worktree metadata', () => { const oversizedQuery = 'secret-worktree-palette-search'.repeat(WORKTREE_PALETTE_QUERY_MAX_BYTES) const worktree = { diff --git a/src/shared/emoji-shortcode-catalog.ts b/src/shared/emoji-shortcode-catalog.ts new file mode 100644 index 00000000000..fba621b7a9c --- /dev/null +++ b/src/shared/emoji-shortcode-catalog.ts @@ -0,0 +1,49 @@ +import emojiShortcodes from 'emojibase-data/en/shortcodes/github.json' + +export type StandardEmojiShortcodeEntry = { + emoji: string + shortcode: string +} + +export const STANDARD_EMOJI_SHORTCODE_ENTRIES: readonly StandardEmojiShortcodeEntry[] = + Object.entries(emojiShortcodes).flatMap(([hexcode, value]) => { + const shortcodes = typeof value === 'string' ? [value] : value + const emoji = hexcodeToEmoji(hexcode) + return shortcodes.map((shortcode) => ({ emoji, shortcode })) + }) + +const PRIMARY_SHORTCODE_BY_EMOJI = new Map( + Object.entries(emojiShortcodes).map(([hexcode, value]) => { + const shortcodes = typeof value === 'string' ? [value] : value + const shortcode = shortcodes.find((candidate) => /^[a-z]/i.test(candidate)) ?? shortcodes[0] + return [normalizeEmojiLookup(hexcodeToEmoji(hexcode)), shortcode] + }) +) + +const EMOJI_SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' }) + +export function replaceKnownEmojiWithShortcodes(input: string): string { + return Array.from(EMOJI_SEGMENTER.segment(input), ({ segment }) => { + const shortcode = PRIMARY_SHORTCODE_BY_EMOJI.get(normalizeEmojiLookup(segment)) + return shortcode ? ` ${shortcode.replaceAll('_', '-')} ` : segment + }).join('') +} + +function normalizeEmojiLookup(emoji: string): string { + return Array.from(emoji) + .filter((character) => { + const codepoint = character.codePointAt(0) + return ( + character !== '\ufe0f' && + (codepoint === undefined || codepoint < 0x1f3fb || codepoint > 0x1f3ff) + ) + }) + .join('') +} + +function hexcodeToEmoji(hexcode: string): string { + return hexcode + .split('-') + .map((codepoint) => String.fromCodePoint(Number.parseInt(codepoint, 16))) + .join('') +} diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 14967d126b0..d290bea0844 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -178,8 +178,7 @@ test.describe('Create Workspace', () => { const worktrees = Object.values(window.__store!.getState().worktreesByRepo).flat() return worktrees.find((worktree) => worktree.displayName === displayName)?.branch ?? null }, workspaceName) - expect(branch).not.toBeNull() - expect(branch).not.toMatch(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/u) + expect(branch).toBe('refs/heads/rocket-test-tube-sparkles') } finally { await orcaPage .evaluate(() => { From 49fbe5231d0a4d92498180a1ac7b330b2246183e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:44:42 -0700 Subject: [PATCH 10/53] feat(workspaces): add emoji shortcode picker --- ...SmartWorkspaceNameField.ime-enter.test.tsx | 57 ++++++++ .../new-workspace/SmartWorkspaceNameField.tsx | 131 +++++++++++++++++- .../WorkspaceEmojiSuggestionPopover.tsx | 78 +++++++++++ src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../lib/workspace-emoji-shortcodes.test.ts | 58 ++++++++ .../src/lib/workspace-emoji-shortcodes.ts | 112 +++++++++++++++ tests/e2e/worktree.spec.ts | 37 +++++ 11 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx create mode 100644 src/renderer/src/lib/workspace-emoji-shortcodes.test.ts create mode 100644 src/renderer/src/lib/workspace-emoji-shortcodes.ts diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx index c0414ab484b..fd828b0b202 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.ime-enter.test.tsx @@ -146,6 +146,31 @@ function renderSmartField(spies: { return input } +function renderEmojiField(value: string, onValueChange: (value: string) => void): HTMLInputElement { + act(() => { + root.render( + + ) + }) + const input = container.querySelector('[data-workspace-name-input="true"]') + if (!input) { + throw new Error('workspace name input not rendered') + } + return input +} + // Why: the guard must short-circuit before the `open && rows.length > 0` // row-select branch, so drive the field into that state — smart mode with a // typed value synchronously yields a highlighted "use this name" row. @@ -235,3 +260,35 @@ describe('SmartWorkspaceNameField IME Enter guard', () => { expect(onPlainEnter).not.toHaveBeenCalled() }) }) + +describe('SmartWorkspaceNameField emoji shortcodes', () => { + it('replaces a completed shortcode as it is typed', () => { + const onValueChange = vi.fn() + const input = renderEmojiField('Launch ', onValueChange) + + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( + input, + 'Launch :wink: experiment' + ) + input.setSelectionRange(13, 13) + input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText' })) + }) + + expect(onValueChange).toHaveBeenCalledWith('Launch 😉 experiment') + }) + + it('accepts the highlighted shortcode suggestion with Enter', () => { + const onValueChange = vi.fn() + const input = renderEmojiField('Launch :wink', onValueChange) + + act(() => { + input.focus() + input.setSelectionRange(12, 12) + input.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + pressEnter(input) + + expect(onValueChange).toHaveBeenCalledWith('Launch 😉') + }) +}) diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index a655d98d1e7..891ec922682 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -88,6 +88,15 @@ import { getGitHubRuntimeRepoId, getGitHubSourceRuntimeTarget } from '@/lib/github-source-runtime-context' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes, + type WorkspaceEmojiReplacement, + type WorkspaceEmojiSuggestion +} from '@/lib/workspace-emoji-shortcodes' +import { WorkspaceEmojiSuggestionPopover } from './WorkspaceEmojiSuggestionPopover' type RepoOption = ReturnType['repos'][number] const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = [] @@ -308,6 +317,8 @@ export default function SmartWorkspaceNameField({ const [branchesLoading, setBranchesLoading] = useState(false) const [linearLoading, setLinearLoading] = useState(false) const [commandValue, setCommandValue] = useState('') + const [emojiCommandValue, setEmojiCommandValue] = useState('') + const [emojiCursor, setEmojiCursor] = useState(null) const localInputRef = useRef(null) const focusedSelectedSourceKeyRef = useRef(null) const tabsListRef = useRef(null) @@ -1104,6 +1115,33 @@ export default function SmartWorkspaceNameField({ isQueryStale, sourceIntent }) + const activeEmojiShortcode = useMemo( + () => getActiveWorkspaceEmojiShortcode(value, emojiCursor), + [emojiCursor, value] + ) + const emojiSuggestions = useMemo( + () => + activeEmojiShortcode + ? searchWorkspaceEmojiShortcodes(activeEmojiShortcode.query) + : ([] as WorkspaceEmojiSuggestion[]), + [activeEmojiShortcode] + ) + const emojiMenuOpen = + !disabled && + selectedSource === null && + activeEmojiShortcode !== null && + emojiSuggestions.length > 0 + const resolvedEmojiCommandValue = emojiSuggestions.some( + (suggestion) => `emoji:${suggestion.shortcode}` === emojiCommandValue + ) + ? emojiCommandValue + : emojiSuggestions[0] + ? `emoji:${emojiSuggestions[0].shortcode}` + : '' + const selectedEmojiSuggestion = + emojiSuggestions.find( + (suggestion) => `emoji:${suggestion.shortcode}` === resolvedEmojiCommandValue + ) ?? null const loading = githubLoading || gitlabLoading || branchesLoading || linearLoading const ActiveInputIcon = mode === 'text' ? CaseSensitive : loading ? LoaderCircle : Search @@ -1128,6 +1166,30 @@ export default function SmartWorkspaceNameField({ [onBranchSelect, onGitHubItemSelect, onGitLabItemSelect, onLinearIssueSelect, onValueChange] ) + const applyEmojiReplacement = useCallback( + (replacement: WorkspaceEmojiReplacement): void => { + onValueChange(replacement.value) + setEmojiCursor(null) + cancelLocalInputFocusFrame() + localInputFocusFrameRef.current = requestAnimationFrame(() => { + localInputFocusFrameRef.current = null + localInputRef.current?.focus({ preventScroll: true }) + localInputRef.current?.setSelectionRange(replacement.cursor, replacement.cursor) + }) + }, + [cancelLocalInputFocusFrame, onValueChange] + ) + + const handleEmojiSelect = useCallback( + (suggestion: WorkspaceEmojiSuggestion): void => { + if (!activeEmojiShortcode) { + return + } + applyEmojiReplacement(applyWorkspaceEmojiSuggestion(value, activeEmojiShortcode, suggestion)) + }, + [activeEmojiShortcode, applyEmojiReplacement, value] + ) + const acceptGitHubLink = useCallback( async (targetRepo: RepoOption): Promise => { if (!crossRepoPrompt) { @@ -1450,8 +1512,20 @@ export default function SmartWorkspaceNameField({ setOpen(true) } }} + onClick={(event) => setEmojiCursor(event.currentTarget.selectionStart)} onChange={(event) => { - onValueChange(event.target.value) + const nextValue = event.target.value + const nextCursor = event.target.selectionStart + const completedEmoji = replaceCompletedWorkspaceEmojiShortcode( + nextValue, + nextCursor + ) + if (completedEmoji) { + applyEmojiReplacement(completedEmoji) + return + } + onValueChange(nextValue) + setEmojiCursor(nextCursor) if (!disabled && mode !== 'text') { markSourcePopoverUserEngaged() setOpen(true) @@ -1460,8 +1534,10 @@ export default function SmartWorkspaceNameField({ onFocus={(event) => { // Why: only open on focus from another composer control (Tab); dialog autofocus from outside stays suppressed. if (!isComposerFieldToFieldFocus(event)) { + setEmojiCursor(event.currentTarget.selectionStart) return } + setEmojiCursor(event.currentTarget.selectionStart) markSourcePopoverUserEngaged() tryOpenSourcePopover() }} @@ -1476,6 +1552,20 @@ export default function SmartWorkspaceNameField({ return } } + if (emojiMenuOpen && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + event.preventDefault() + event.stopPropagation() + const selectedIndex = emojiSuggestions.findIndex( + (suggestion) => + `emoji:${suggestion.shortcode}` === resolvedEmojiCommandValue + ) + const direction = event.key === 'ArrowDown' ? 1 : -1 + const nextIndex = + (selectedIndex + direction + emojiSuggestions.length) % + emojiSuggestions.length + setEmojiCommandValue(`emoji:${emojiSuggestions[nextIndex].shortcode}`) + return + } if ( event.key === 'Enter' && !event.metaKey && @@ -1490,6 +1580,12 @@ export default function SmartWorkspaceNameField({ if (isImeCompositionKeyDown(event)) { return } + if (emojiMenuOpen && selectedEmojiSuggestion) { + event.preventDefault() + event.stopPropagation() + handleEmojiSelect(selectedEmojiSuggestion) + return + } if (open && rows.length > 0) { const row = rows.find((entry) => entry.value === resolvedCommandValue) if (row) { @@ -1501,6 +1597,22 @@ export default function SmartWorkspaceNameField({ } onPlainEnter?.() } + if ( + event.key === 'Tab' && + !event.shiftKey && + emojiMenuOpen && + selectedEmojiSuggestion + ) { + event.preventDefault() + event.stopPropagation() + handleEmojiSelect(selectedEmojiSuggestion) + return + } + if (event.key === 'Escape' && emojiMenuOpen) { + event.stopPropagation() + setEmojiCursor(null) + return + } if (event.key === 'Escape' && open) { event.stopPropagation() setOpen(false) @@ -1517,8 +1629,11 @@ export default function SmartWorkspaceNameField({
+ { + if (!next) { + setEmojiCursor(null) + } + }} + /> !next && dismissCrossRepoPrompt()} diff --git a/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx b/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx new file mode 100644 index 00000000000..209fd72311c --- /dev/null +++ b/src/renderer/src/components/new-workspace/WorkspaceEmojiSuggestionPopover.tsx @@ -0,0 +1,78 @@ +import type { RefObject } from 'react' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import type { WorkspaceEmojiSuggestion } from '@/lib/workspace-emoji-shortcodes' + +type WorkspaceEmojiSuggestionPopoverProps = { + anchorRef: RefObject + commandValue: string + heading: string + onCommandValueChange: (value: string) => void + onOpenChange: (open: boolean) => void + onSelect: (suggestion: WorkspaceEmojiSuggestion) => void + open: boolean + suggestions: readonly WorkspaceEmojiSuggestion[] +} + +export function WorkspaceEmojiSuggestionPopover({ + anchorRef, + commandValue, + heading, + onCommandValueChange, + onOpenChange, + onSelect, + open, + suggestions +}: WorkspaceEmojiSuggestionPopoverProps): React.JSX.Element { + return ( + + } /> + event.preventDefault()} + onPointerDownOutside={(event) => { + if (anchorRef.current?.contains(event.target as Node)) { + event.preventDefault() + } + }} + onFocusOutside={(event) => { + if (anchorRef.current?.contains(event.target as Node)) { + event.preventDefault() + } + }} + > + + + + {suggestions.map((suggestion) => ( + onSelect(suggestion)} + className="gap-2 px-3 py-2 text-xs" + > + + {suggestion.emoji} + + + :{suggestion.shortcode}: + + + ))} + + + + + + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4e497d0f10a..e7752c0551c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -11404,7 +11404,8 @@ "searchGitLab": "Search GitLab MRs and issues", "searchBranches": "Search branches", "searchLinear": "Search Linear issues", - "workspaceName": "Workspace name" + "workspaceName": "Workspace name", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "No hosts are ready for this project.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d3e0baf4ec3..258167d0b89 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -11381,7 +11381,8 @@ "searchGitLab": "Buscar MRs e issues de GitLab", "searchBranches": "Buscar ramas", "searchLinear": "Buscar issues de Linear", - "workspaceName": "Nombre del espacio de trabajo" + "workspaceName": "Nombre del espacio de trabajo", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "No hay hosts listos para este proyecto.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1911a5a1c2c..ef05b2f0b1b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -11381,7 +11381,8 @@ "searchGitLab": "GitLab MR と Issue を検索", "searchBranches": "ブランチを検索", "searchLinear": "Linear Issue を検索", - "workspaceName": "ワークスペース名" + "workspaceName": "ワークスペース名", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "No hosts are ready for this project.", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 653c41c3268..da304a091dd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -11381,7 +11381,8 @@ "searchGitLab": "GitLab MR 및 이슈 검색", "searchBranches": "브랜치 검색", "searchLinear": "Linear 이슈 검색", - "workspaceName": "워크스페이스 이름" + "workspaceName": "워크스페이스 이름", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "이 프로젝트에 준비된 호스트가 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4b567a771fa..b447998dd88 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -11381,7 +11381,8 @@ "searchGitLab": "搜索 GitLab MR 和议题", "searchBranches": "搜索分支", "searchLinear": "搜索 Linear 议题", - "workspaceName": "工作区名称" + "workspaceName": "工作区名称", + "emoji": "Emoji" }, "ProjectHostSetupCombobox": { "empty": "此项目没有已就绪的主机。", diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts new file mode 100644 index 00000000000..693af72e69c --- /dev/null +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + applyWorkspaceEmojiSuggestion, + getActiveWorkspaceEmojiShortcode, + replaceCompletedWorkspaceEmojiShortcode, + searchWorkspaceEmojiShortcodes +} from './workspace-emoji-shortcodes' + +describe('workspace emoji shortcodes', () => { + it('finds standard emoji by Slack-style shortcode', () => { + expect(searchWorkspaceEmojiShortcodes('wink', 1)).toEqual([{ emoji: '😉', shortcode: 'wink' }]) + }) + + it('ranks an exact shortcode before longer aliases', () => { + expect(searchWorkspaceEmojiShortcodes('heart', 3)[0]).toMatchObject({ + shortcode: 'heart' + }) + }) + + it('deduplicates aliases that resolve to the same emoji', () => { + const suggestions = searchWorkspaceEmojiShortcodes('wink') + expect(new Set(suggestions.map((suggestion) => suggestion.emoji)).size).toBe(suggestions.length) + }) + + it('detects a shortcode at the caret without matching URLs or mid-word colons', () => { + expect(getActiveWorkspaceEmojiShortcode('Ship :win', 9)).toEqual({ + start: 5, + end: 9, + query: 'win' + }) + expect(getActiveWorkspaceEmojiShortcode('https://example.com', 19)).toBeNull() + expect(getActiveWorkspaceEmojiShortcode('fix:win', 7)).toBeNull() + }) + + it('replaces a completed shortcode and preserves text after the caret', () => { + expect(replaceCompletedWorkspaceEmojiShortcode('Ship :wink: today', 11)).toEqual({ + value: 'Ship 😉 today', + cursor: 7 + }) + }) + + it('leaves unknown completed shortcodes unchanged', () => { + expect(replaceCompletedWorkspaceEmojiShortcode(':orca_custom:', 13)).toBeNull() + }) + + it('applies a selected suggestion to the active shortcode range', () => { + expect( + applyWorkspaceEmojiSuggestion( + 'Ship :win today', + { start: 5, end: 9, query: 'win' }, + { emoji: '😉', shortcode: 'wink' } + ) + ).toEqual({ + value: 'Ship 😉 today', + cursor: 7 + }) + }) +}) diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts new file mode 100644 index 00000000000..e97d0267192 --- /dev/null +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -0,0 +1,112 @@ +import { + STANDARD_EMOJI_SHORTCODE_ENTRIES, + type StandardEmojiShortcodeEntry +} from '../../../shared/emoji-shortcode-catalog' + +export type WorkspaceEmojiSuggestion = StandardEmojiShortcodeEntry + +export type ActiveWorkspaceEmojiShortcode = { + end: number + query: string + start: number +} + +export type WorkspaceEmojiReplacement = { + cursor: number + value: string +} + +const SHORTCODE_ENTRIES = STANDARD_EMOJI_SHORTCODE_ENTRIES + +const EXACT_SHORTCODE = new Map( + SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) +) + +export function searchWorkspaceEmojiShortcodes( + query: string, + limit = 8 +): WorkspaceEmojiSuggestion[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery || limit <= 0) { + return [] + } + + const matches = SHORTCODE_ENTRIES.filter(({ shortcode }) => + shortcode.startsWith(normalizedQuery) + ).sort( + (left, right) => + Number(right.shortcode === normalizedQuery) - Number(left.shortcode === normalizedQuery) || + left.shortcode.length - right.shortcode.length || + left.shortcode.localeCompare(right.shortcode) + ) + const seenEmoji = new Set() + const suggestions: WorkspaceEmojiSuggestion[] = [] + for (const { emoji, shortcode } of matches) { + if (seenEmoji.has(emoji)) { + continue + } + seenEmoji.add(emoji) + suggestions.push({ emoji, shortcode }) + if (suggestions.length === limit) { + break + } + } + return suggestions +} + +export function getActiveWorkspaceEmojiShortcode( + value: string, + cursor: number | null +): ActiveWorkspaceEmojiShortcode | null { + if (cursor === null || cursor < 0 || cursor > value.length) { + return null + } + const match = value.slice(0, cursor).match(/(^|\s):([a-z0-9_+-]{1,40})$/i) + if (!match) { + return null + } + return { + start: cursor - match[2].length - 1, + end: cursor, + query: match[2].toLowerCase() + } +} + +export function replaceCompletedWorkspaceEmojiShortcode( + value: string, + cursor: number | null +): WorkspaceEmojiReplacement | null { + if (cursor === null || cursor < 0 || cursor > value.length) { + return null + } + const match = value.slice(0, cursor).match(/(^|\s):([a-z0-9_+-]{1,40}):$/i) + if (!match) { + return null + } + const suggestion = EXACT_SHORTCODE.get(match[2].toLowerCase()) + if (!suggestion) { + return null + } + const start = cursor - match[2].length - 2 + return replaceWorkspaceEmojiRange(value, start, cursor, suggestion.emoji) +} + +export function applyWorkspaceEmojiSuggestion( + value: string, + active: ActiveWorkspaceEmojiShortcode, + suggestion: WorkspaceEmojiSuggestion +): WorkspaceEmojiReplacement { + return replaceWorkspaceEmojiRange(value, active.start, active.end, suggestion.emoji) +} + +function replaceWorkspaceEmojiRange( + value: string, + start: number, + end: number, + emoji: string +): WorkspaceEmojiReplacement { + return { + value: `${value.slice(0, start)}${emoji}${value.slice(end)}`, + cursor: start + emoji.length + } +} diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index d290bea0844..ffb602686da 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -190,6 +190,43 @@ test.describe('Create Workspace', () => { } }) + test('enters emoji with Slack-style shortcode suggestions', async ({ orcaPage }) => { + try { + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const nameInput = dialog.getByPlaceholder(/Type a name/i) + await expect(nameInput).toBeVisible() + + await nameInput.pressSequentially('Launch :wink', { delay: 100 }) + const emojiSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') + const sourceSuggestions = orcaPage.locator('[data-workspace-source-suggestions="true"]') + await expect(emojiSuggestions).toBeVisible() + await expect(emojiSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await expect(emojiSuggestions).toHaveAttribute('data-side', 'top') + await expect(sourceSuggestions).toBeVisible() + await expect(sourceSuggestions).toHaveAttribute('data-side', 'bottom') + // Keep both independently positioned suggestion surfaces visible in proof recordings. + await orcaPage.waitForTimeout(750) + + await nameInput.pressSequentially(':') + await expect(nameInput).toHaveValue('Launch 😉') + await expect(orcaPage.getByRole('option', { name: /:wink:/i })).toHaveCount(0) + await nameInput.pressSequentially(' experiment') + await expect(nameInput).toHaveValue('Launch 😉 experiment') + // Keep the asserted result visible in retained proof recordings. + await orcaPage.waitForTimeout(750) + } finally { + await orcaPage + .evaluate(() => { + window.__store?.getState().closeModal() + }) + .catch(() => { + /* page may already be torn down */ + }) + } + }) + test('shows a failed workspace entry when worktree creation fails', async ({ orcaPage }) => { await orcaPage.evaluate(() => { const store = window.__store From 9de4519c820d16e6b4023b35fd184cb6c16077dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:22:06 -0700 Subject: [PATCH 11/53] fix(terminal): gate the developer menu behind Option and unblock manual parking (#11091) The Developer submenu shipped visible on every worktree right-click, and its Park terminal action always refused with "These terminals cannot be parked safely." - reveal the Developer submenu only when Option/Alt is held at right-click, captured at open time so it can't shift rows mid-menu - stop a settled pendingActivationSpawn tag from refusing a manual park: first activation stamps it on every tab and only a fresh updateTabPtyId consumes it, so a reattached tab kept it forever - resolve the single leaf of a rootless layout for parked watcher coverage, so a workspace whose panes never mounted is no longer permanently uncoverable - restore the !isVisible park guard dropped in #11016, which let the workspace being viewed unmount its own terminals - split manual-park eligibility out of the automatic cold-park policy module --- src/renderer/src/components/Terminal.tsx | 9 +- .../sidebar/WorktreeContextMenu.test.ts | 25 +++- .../sidebar/WorktreeContextMenu.tsx | 22 ++- .../WorktreeDeveloperMenuReveal.test.tsx | 139 ++++++++++++++++++ ...terminal-worktree-park-eligibility.test.ts | 63 ++++++++ ...nual-terminal-worktree-park-eligibility.ts | 56 +++++++ .../terminal-hidden-view-parking.test.ts | 31 ---- .../terminal-hidden-view-parking.ts | 20 --- .../terminal-parked-tab-watchers.test.ts | 30 ++++ .../terminal-parked-tab-watchers.ts | 14 +- .../use-manual-terminal-worktree-parking.ts | 6 +- 11 files changed, 356 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts create mode 100644 src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e730d4328ff..237dbafae23 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1101,7 +1101,9 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const parked = - !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) + !isVisible && + !shouldMeasureHiddenWorktree && + effectiveParkedTerminalWorktreeIds.has(workspace.id) if (parked) { for (const tab of tabs) { const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { @@ -2120,7 +2122,9 @@ function Terminal(): React.JSX.Element | null { const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = - !shouldMeasureHiddenWorktree && effectiveParkedTerminalWorktreeIds.has(workspace.id) + !isVisible && + !shouldMeasureHiddenWorktree && + effectiveParkedTerminalWorktreeIds.has(workspace.id) return ( { + it('stays hidden for an ordinary right-click', () => { + expect( + shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed: false, isMultiContext: false }) + ).toBe(false) + }) + + it('reveals when Option/Alt was held at open time', () => { + expect( + shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed: true, isMultiContext: false }) + ).toBe(true) + }) + + // Why: the parking action targets one workspace, so it must not appear for a + // multi-select context even with the modifier held. + it('stays hidden for a multi-workspace selection', () => { + expect( + shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed: true, isMultiContext: true }) + ).toBe(false) + }) +}) + describe('selectMenuScopedMap (delete-teardown re-render guard)', () => { // Why: the closed menu wrapper must stay inert to delete teardown's high-churn // set()s. The guard is referential stability — when closed, the selector must diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index ebaf99d2237..898e406ed8f 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -104,6 +104,16 @@ export function selectMenuScopedMap(menuOpen: boolean, live: T, empty: T): T return menuOpen ? live : empty } +// Why: the Developer submenu is hidden by default and revealed only by holding +// Option/Alt at right-click. altKey is the same physical key on every platform +// (Option on macOS, Alt on Windows/Linux), so no platform branch is needed. +export function shouldRevealWorktreeDeveloperMenu(args: { + developerMenuRevealed: boolean + isMultiContext: boolean +}): boolean { + return args.developerMenuRevealed && !args.isMultiContext +} + export function hasWorktreeParentLink( worktree: Worktree, lineageById: AppState['worktreeLineageById'], @@ -331,6 +341,11 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const repo = useRepoById(worktree.repoId) const deleteState = useAppStore((s) => s.deleteStateByWorktreeId[worktree.id]) const [menuOpen, setMenuOpen] = useState(false) + // Why: the Developer submenu is a power-user affordance, so it is revealed by + // holding Option/Alt at right-click — captured at open time (like the Help + // menu's admin options) so the submenu can't appear or vanish mid-menu and + // shift the rows under the pointer. + const [developerMenuRevealed, setDeveloperMenuRevealed] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) const [contextWorktrees, setContextWorktrees] = useState( effectiveSelectedWorktrees @@ -463,6 +478,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const setMenuOpenState = useCallback( (open: boolean) => { setMenuOpen(open) + if (!open) { + // Why: the reveal is per-open, so a later plain right-click can't inherit it. + setDeveloperMenuRevealed(false) + } onOpenChange?.(open) }, [onOpenChange] @@ -731,6 +750,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ event.preventDefault() contextMenuOpenedAtRef.current = Date.now() window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setDeveloperMenuRevealed(event.altKey) setContextWorktrees(onContextMenuSelect?.(event) ?? effectiveSelectedWorktrees) const bounds = event.currentTarget.getBoundingClientRect() setMenuPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }) @@ -925,7 +945,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ ) : null} - {!isMultiContext ? ( + {shouldRevealWorktreeDeveloperMenu({ developerMenuRevealed, isMultiContext }) ? ( <> diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx new file mode 100644 index 00000000000..7816939efb2 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +/** + * Why a render test on top of shouldRevealWorktreeDeveloperMenu's unit tests: + * the shipped bug was the submenu rendering unconditionally, i.e. a WIRING + * defect. These assert the modifier actually reaches the reveal state through + * the real right-click handler. + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../../shared/types' + +vi.mock('@/store', () => { + const state = { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + deleteStateByWorktreeId: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + workspaceStatuses: [], + projectGroups: [], + settings: null, + updateWorktreeMeta: vi.fn(), + setWorktreesPinnedAndReveal: vi.fn(), + openModal: vi.fn(), + createProjectGroup: vi.fn(), + moveProjectToGroup: vi.fn(), + deleteFolderWorkspace: vi.fn(), + setActiveWorktree: vi.fn() + } + return { + useAppStore: Object.assign((selector: (value: typeof state) => unknown) => selector(state), { + getState: () => state + }) + } +}) + +vi.mock('@/store/selectors', () => ({ + useAllWorktrees: () => [], + useRepoById: () => null, + useRepoMap: () => new Map(), + useWorktreeMap: () => new Map() +})) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +// Why: Radix portals/submenus need real layout; a passthrough keeps the test on +// the reveal decision rather than menu mechanics. +vi.mock('@/components/ui/dropdown-menu', () => { + const passthrough = ({ children }: { children?: ReactNode }) => <>{children} + return { + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: passthrough, + DropdownMenuLabel: passthrough, + DropdownMenuRadioGroup: passthrough, + DropdownMenuRadioItem: passthrough, + DropdownMenuSeparator: () => null, + DropdownMenuSub: passthrough, + DropdownMenuSubContent: passthrough, + DropdownMenuSubTrigger: passthrough, + DropdownMenuTrigger: passthrough + } +}) + +vi.mock('./WorktreeOpenInMenu', () => ({ WorktreeOpenInSubMenu: () => null })) +vi.mock('./ProjectGroupNameDialog', () => ({ ProjectGroupNameDialog: () => null })) +vi.mock('./WorktreeParentPickerPopover', () => ({ WorktreeParentPickerPopover: () => null })) +vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +vi.mock('./delete-worktree-flow', () => ({ + runWorktreeBatchDelete: vi.fn(), + runWorktreeDelete: vi.fn() +})) +vi.mock('./sleep-worktree-flow', () => ({ runSleepWorktrees: vi.fn() })) + +const WorktreeContextMenu = (await import('./WorktreeContextMenu')).default + +function makeWorktree(): Worktree { + return { + id: 'repo-1::/repo/wt', + repoId: 'repo-1', + path: '/repo/wt', + displayName: 'wt', + branch: 'wt', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } as Worktree +} + +// Why: assert on text content — the passthrough menu mock renders each label as +// a bare text node beside its icon, which getByText's element matching skips. +function openContextMenu(altKey: boolean): string { + const { container } = render( + +
card
+
+ ) + fireEvent.contextMenu(screen.getByTestId('card'), { altKey }) + return container.textContent ?? '' +} + +describe('Developer submenu reveal', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('hides the Developer submenu on a plain right-click', () => { + const text = openContextMenu(false) + + expect(text).toContain('Sleep') + expect(text).not.toContain('Developer') + expect(text).not.toContain('Park terminal') + }) + + it('reveals the Developer submenu when Option/Alt is held', () => { + const text = openContextMenu(true) + + expect(text).toContain('Developer') + expect(text).toContain('Park terminal') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts new file mode 100644 index 00000000000..07f16fcbeb4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { canManuallyParkTerminalWorktreeRenderers } from './manual-terminal-worktree-park-eligibility' + +describe('canManuallyParkTerminalWorktreeRenderers', () => { + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true, + hasLivePty: () => true + } + + it('bypasses visibility timing for snapshot-backed local terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + // Why: first activation stamps pendingActivationSpawn on every tab and only a + // fresh updateTabPtyId consumes it, so a reattached tab keeps it forever — + // that residue must not permanently refuse a manual park. + it('ignores a settled activation-spawn tag once the tab has a live PTY', () => { + const terminalTabs = [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true as const } + ] + + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs })).toBe(true) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs, + hasLivePty: () => false + }) + ).toBe(false) + }) + + it('still refuses a multi-pane activation spawn that has not finished', () => { + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ], + hasLivePty: () => false + }) + ).toBe(false) + }) + + it('preserves safety gates for empty, pending, remote, and disabled terminals', () => { + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] } + }) + ).toBe(false) + expect( + canManuallyParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }] + }) + ).toBe(false) + expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts new file mode 100644 index 00000000000..b5597dea6a1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/manual-terminal-worktree-park-eligibility.ts @@ -0,0 +1,56 @@ +/** + * Eligibility for the on-demand ("Park terminal") path. + * + * Why split from terminal-hidden-view-parking: that module owns the automatic + * cold-park policy — the hysteresis clock, hot-retain cap, and recheck + * deadlines. Manual parking reuses its safety gates but deliberately bypasses + * every time-based one, so the divergence lives here instead of accruing + * `nowMs: 0` special cases inside the policy module. + */ +import { + canParkTerminalWorktreeRenderers, + type ColdParkableTerminalTab +} from './terminal-hidden-view-parking' + +// Why: pendingActivationSpawn is sidebar-sort suppression, not a spawn-in-flight +// signal. First activation stamps it on every tab and only a fresh +// updateTabPtyId consumes it, so a tab whose PTY was reattached (not respawned) +// keeps it forever. The automatic path can wait it out; a manual park must not +// be refused by that residue, so drop it wherever the tab already has a live +// PTY — the only trustworthy "spawn settled" signal (tab.ptyId is a wake hint). +function withoutSettledActivationSpawn( + tabs: readonly ColdParkableTerminalTab[], + hasLivePty: (tabId: string) => boolean +): ColdParkableTerminalTab[] { + return tabs.map((tab) => { + if (!tab.pendingActivationSpawn || !hasLivePty(tab.id)) { + return tab + } + const { pendingActivationSpawn: _settled, ...rest } = tab + return rest + }) +} + +export function canManuallyParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + hasLivePty: (tabId: string) => boolean +}): boolean { + return ( + args.terminalTabs.length > 0 && + canParkTerminalWorktreeRenderers({ + ...args, + terminalTabs: withoutSettledActivationSpawn(args.terminalTabs, args.hasLivePty), + // Why: the user asked for this park explicitly, so skip the hidden-duration + // hysteresis the automatic path uses — but keep every safety gate below it. + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs: 0, + nowMs: 0, + coldParkDelayMs: 0 + }) + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts index cb1c698fc15..5c8026b74d0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -7,7 +7,6 @@ import { TERMINAL_TAB_HOT_RETAIN_MS, TERMINAL_WORKTREE_HOT_RETAIN_MS, TERMINAL_WORKTREE_PARK_DELAY_MS, - canManuallyParkTerminalWorktreeRenderers, canParkTerminalTabRenderer, canParkTerminalWorktreeRenderers, getTerminalTabColdParkRecheckDelayMs, @@ -182,36 +181,6 @@ describe('canParkTerminalTabRenderer', () => { }) }) -describe('canManuallyParkTerminalWorktreeRenderers', () => { - const base = { - worktreeId: 'repo::/worktree', - terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], - pendingStartupByTabId: {}, - parkingEnabled: true - } - - it('bypasses visibility timing for snapshot-backed local terminals', () => { - expect(canManuallyParkTerminalWorktreeRenderers(base)).toBe(true) - }) - - it('preserves safety gates for empty, pending, remote, and disabled terminals', () => { - expect(canManuallyParkTerminalWorktreeRenderers({ ...base, terminalTabs: [] })).toBe(false) - expect( - canManuallyParkTerminalWorktreeRenderers({ - ...base, - pendingStartupByTabId: { 'tab-1': ['echo', 'pending'] } - }) - ).toBe(false) - expect( - canManuallyParkTerminalWorktreeRenderers({ - ...base, - terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:ssh-1@@pty-1' }] - }) - ).toBe(false) - expect(canManuallyParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) - }) -}) - describe('selectColdParkedTerminalWorktrees', () => { const nowMs = 500_000 diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index 4701ff727a3..f4b434c948b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -107,26 +107,6 @@ export function canParkTerminalWorktreeRenderers(args: { }) } -export function canManuallyParkTerminalWorktreeRenderers(args: { - worktreeId: string - terminalTabs: readonly ColdParkableTerminalTab[] - pendingStartupByTabId: Readonly> - parkingEnabled: boolean -}): boolean { - return ( - args.terminalTabs.length > 0 && - canParkTerminalWorktreeRenderers({ - ...args, - isVisible: false, - shouldMeasureHiddenWorktree: false, - hasActivityTerminalPortal: false, - hiddenSinceMs: 0, - nowMs: 0, - coldParkDelayMs: 0 - }) - ) -} - export function canParkTerminalTabRenderer(args: { worktreeId: string terminalTab: TerminalTabColdParkCandidate diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index ba963681a49..bd044d1578f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -764,6 +764,36 @@ describe('fallbackParkedPaneCandidates', () => { ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) }) + // Why: a tab that never mounted a pane persists a rootless layout. Walking + // only `root` yielded zero candidates, so watcher coverage refused it and a + // manual park could never succeed for a workspace the user had not visited. + it('resolves the single leaf of a rootless layout', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('returns nothing for a rootless layout with no resolvable leaf', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: null, expandedLeafId: null } + }, + runtimePaneTitlesByTabId: {} + } as never) + ).toEqual([]) + }) + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { expect( fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index f7644935509..4328f5db980 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -9,7 +9,10 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '@/store' import { closeTerminalTab } from '../terminal/terminal-tab-actions' -import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { + collectLeafIdsInOrder, + resolveRootlessTerminalLayoutLeafId +} from './terminal-layout-leaf-ids' import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { subscribeToPtyExit } from './pty-dispatcher' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' @@ -54,7 +57,14 @@ export function fallbackParkedPaneCandidates( state: ParkedPaneFallbackState ): ParkedTerminalPaneCapture[] { const layout = state.terminalLayoutsByTabId[tab.id] - const leafIds = collectLeafIdsInOrder(layout?.root) + // Why: a tab that never mounted a pane persists a rootless layout, so there is + // no root to walk. replayTerminalLayout resolves the single leaf for that same + // shape — match it here, or such a tab is permanently uncoverable and can + // never park. + const rootLeafIds = collectLeafIdsInOrder(layout?.root) + const rootlessLeafId = layout ? resolveRootlessTerminalLayoutLeafId(layout) : null + const leafIds = + rootLeafIds.length > 0 ? rootLeafIds : rootlessLeafId !== null ? [rootlessLeafId] : [] if (leafIds.length === 0) { return [] } diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts index 2d1ed09afce..d0096935e67 100644 --- a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.ts @@ -2,13 +2,14 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { MANUAL_TERMINAL_WORKTREE_PARK_EVENT, takeAllPendingManualTerminalWorktreeParks, takePendingManualTerminalWorktreePark, type ManualTerminalWorktreeParkDetail } from '@/lib/manual-terminal-worktree-parking' -import { canManuallyParkTerminalWorktreeRenderers } from './terminal-hidden-view-parking' +import { canManuallyParkTerminalWorktreeRenderers } from './manual-terminal-worktree-park-eligibility' import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' function addWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { @@ -53,7 +54,8 @@ export function useManualTerminalWorktreeParking(args: { worktreeId, terminalTabs, pendingStartupByTabId: state.pendingStartupByTabId, - parkingEnabled: state.settings?.terminalHiddenViewParking !== false + parkingEnabled: state.settings?.terminalHiddenViewParking !== false, + hasLivePty: (tabId) => tabHasLivePty(state.ptyIdsByTabId, tabId) }) && terminalTabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab)) if (!canPark) { toast.warning( From 3f5098a0f2fb255deecb22e0a2d4095ad6ba0cca Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 27 Jul 2026 22:25:52 -0700 Subject: [PATCH 12/53] fix(workspaces): hide false repo error for remote servers (#11050) --- src/renderer/src/hooks/useComposerState.ts | 2 ++ .../useEphemeralVmRecipeOptions.test.tsx | 24 ++++++++++++++++++- .../src/hooks/useEphemeralVmRecipeOptions.ts | 3 +++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2411ed3b93f..934065ab3b6 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -140,6 +140,7 @@ import { useFolderWorkspaceComposerPathStatus } from '@/components/sidebar/folde import { submitFolderWorkspaceCreate } from '@/components/sidebar/folder-workspace-composer-submit' import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry' import { + getRepoExecutionHostId, normalizeExecutionHostId, parseExecutionHostId, type ExecutionHostId @@ -826,6 +827,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId: selectedRecipeRepoId, repoIsGit: selectedRepoIsGit, repoConnectionId: selectedRecipeRepoConnectionId, + repoExecutionHostId: selectedRepo ? getRepoExecutionHostId(selectedRepo) : null, projectGroupTarget: isProjectGroupTarget, initialRecipeId: initialEphemeralVmRecipeId }) diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx index 6439bc43a68..5f10b7a9908 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx @@ -59,17 +59,20 @@ function installApi(listRecipes: ReturnType): { function Harness({ repoId, initialRecipeId, - enabled = true + enabled = true, + repoExecutionHostId = 'local' }: { repoId: string initialRecipeId?: string enabled?: boolean + repoExecutionHostId?: 'local' | `runtime:${string}` }): React.JSX.Element { const state = useEphemeralVmRecipeOptions({ enabled, repoId, repoIsGit: true, repoConnectionId: null, + repoExecutionHostId, projectGroupTarget: false, initialRecipeId }) @@ -78,6 +81,7 @@ function Harness({ {repoId} {state.recipes.map((recipe) => recipe.id).join(',')} {state.selectedRecipeId ?? 'none'} + {state.error ?? 'none'}
) } @@ -145,4 +149,22 @@ describe('useEphemeralVmRecipeOptions', () => { expect(listRecipes).not.toHaveBeenCalled() }) + + it('does not send a paired-runtime repo id to local recipe discovery', async () => { + const listRecipes = vi.fn().mockResolvedValue({ + status: 'error', + repoPath: null, + diagnostics: [], + recipes: [], + message: 'Repo not found: repo-runtime' + }) + installApi(listRecipes) + + const { container } = await render( + + ) + + expect(listRecipes).not.toHaveBeenCalled() + expect(container.querySelector('[data-testid="error"]')?.textContent).toBe('none') + }) }) diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts index 3bee2c8c6c3..6ebf489f894 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import type { OrcaVmRecipe } from '../../../shared/types' type EphemeralVmRecipeOptionsArgs = { @@ -6,6 +7,7 @@ type EphemeralVmRecipeOptionsArgs = { repoId: string | null repoIsGit: boolean repoConnectionId: string | null + repoExecutionHostId: ExecutionHostId | null projectGroupTarget: boolean initialRecipeId?: string } @@ -25,6 +27,7 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs): Boolean(args.repoId) && args.repoIsGit && !args.repoConnectionId && + args.repoExecutionHostId === LOCAL_EXECUTION_HOST_ID && !args.projectGroupTarget const load = useCallback( From 700cde83e030b2bc5b7af44dfd3b03a4656c3b02 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:02:21 -0700 Subject: [PATCH 13/53] i18n: translate orchestration page and workflow messages (#11097) Add translations for OrchestrationPage coordinator and child PR names, plus agent workflow status messages (initial states and progress beats) across all supported languages (English, Spanish, Japanese, Korean, Simplified Chinese). --- .../OrchestrationPage.tsx | 15 ++++++++-- src/renderer/src/i18n/locales/en.json | 19 +++++++++++- src/renderer/src/i18n/locales/es.json | 21 ++++++++++++-- src/renderer/src/i18n/locales/ja.json | 29 +++++++++++++++---- src/renderer/src/i18n/locales/ko.json | 19 +++++++++++- src/renderer/src/i18n/locales/zh.json | 21 ++++++++++++-- 6 files changed, 109 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx b/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx index 0ef1cd631ef..e43901ef28b 100644 --- a/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx +++ b/src/renderer/src/components/feature-wall/agents-orchestration/OrchestrationPage.tsx @@ -292,7 +292,10 @@ export function OrchestrationPage(props: {
Date: Mon, 27 Jul 2026 23:26:54 -0700 Subject: [PATCH 14/53] fix(skills): stop reporting a failed update when the CLI succeeded (#11105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Update skills dialog showed "The update didn't finish" / "Some skills could not be updated" with an armed Retry, directly above the runner's own log line saying "All global skills are up to date" — on a clean exit 0. `skills update` compares its lock's recorded hash against the source and never reads disk (dist/cli.mjs: `latestHash !== entry.skillFolderHash`). Once the lock has advanced past the installed bytes it prints up-to-date, exits 0 and writes nothing. The copy left behind is a recognised older revision, so the post-run re-scan sees `outdated`, skillUpdateFailedNames counted that as a failed run, and skill-update-run settled to state 'error'. Retry re-ran the same command, which no-op'd again — a closed loop. Reclassify `outdated` as "the command did not converge this", not "the run failed". The freshness badge still marks the copy not-current, so nothing is hidden; the run just stops being blamed for it. A botched write is still caught: a half-written bundle hashes to `unrecognized`, a wholly-degraded or removed copy leaves no convergent placement, and process-level failure still surfaces via the spawn error. Reachable by anyone who updated during the stub conversion window — every bundled skill has a stub -> full -> stub oscillation in its last three registry revisions. --- src/main/skills/skill-update-outcome.test.ts | 28 +++++++++++++++----- src/main/skills/skill-update-outcome.ts | 18 ++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/main/skills/skill-update-outcome.test.ts b/src/main/skills/skill-update-outcome.test.ts index 4aa2ce401b2..9f7d809daa6 100644 --- a/src/main/skills/skill-update-outcome.test.ts +++ b/src/main/skills/skill-update-outcome.test.ts @@ -38,10 +38,13 @@ describe('skillUpdateFailedNames', () => { expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')])).toEqual([]) }) - it('reports a copy the run left outdated', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([ - 'orca-cli' - ]) + // Reversed deliberately. `skills update` compares its lock against the source + // and never reads disk, so once the lock has advanced past the installed bytes + // it prints "up to date", exits 0 and writes nothing — leaving a recognised + // older revision that no retry converges. Blaming the run for that invented a + // failure the CLI never reported and armed a Retry that could not succeed. + it('does not blame the run for a copy the update command cannot converge', () => { + expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([]) }) it('reports a half-written bundle instead of reading it as success', () => { @@ -76,20 +79,31 @@ describe('skillUpdateFailedNames', () => { ).toEqual([]) }) - it('fails the name when any convergent alias was left behind', () => { + // Still fails on a DEGRADED alias — only `outdated` was reclassified, so a + // half-written alias beside a good canonical copy must not read as success. + it('fails the name when any convergent alias was left broken', () => { + expect( + skillUpdateFailedNames( + ['orca-cli'], + [placement('orca-cli', 'current'), placement('orca-cli', 'unrecognized', 'provider-alias')] + ) + ).toEqual(['orca-cli']) + }) + + it('does not fail the name for an alias the command merely left old', () => { expect( skillUpdateFailedNames( ['orca-cli'], [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')] ) - ).toEqual(['orca-cli']) + ).toEqual([]) }) it('judges each requested name independently', () => { expect( skillUpdateFailedNames( ['orca-cli', 'orchestration'], - [placement('orca-cli', 'current'), placement('orchestration', 'outdated')] + [placement('orca-cli', 'current'), placement('orchestration', 'unrecognized')] ) ).toEqual(['orchestration']) }) diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts index 6d75d4b5efc..77a005446a8 100644 --- a/src/main/skills/skill-update-outcome.ts +++ b/src/main/skills/skill-update-outcome.ts @@ -34,6 +34,22 @@ export function skillUpdateFailedNames( } // `newer-known` counts as landed: the CLI pulls from the source repo, which // can be ahead of the revision this build ships in its manifest. - return convergent.some((entry) => entry.status !== 'current' && entry.status !== 'newer-known') + // + // `outdated` is not a failed run either. `skills update` compares its lock's + // recorded hash against the source and never reads disk, so when the lock has + // advanced past the installed bytes it reports "up to date", exits 0, and + // writes nothing. The copy is left intact — a recognised older revision, not + // a broken one — and no amount of retrying converges it. Calling that an + // error invented a failure the CLI never reported and armed a Retry that + // provably could not succeed. The freshness badge still marks the copy + // not-current, so this only stops the run being blamed for it. + // + // A genuinely botched write is still caught: a half-written bundle hashes to + // `unrecognized`, a wholly-degraded or removed copy leaves no convergent + // placement, and process-level failure surfaces through the spawn error. + return convergent.some( + (entry) => + entry.status !== 'current' && entry.status !== 'newer-known' && entry.status !== 'outdated' + ) }) } From c25d85cc4c21d187c5986ab2c8d1d2c3e18a9cce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:28:05 -0700 Subject: [PATCH 15/53] perf(terminal): eliminate adverse control and frame-gate cases (#11045) * perf(terminal): eliminate dense control and frame gate regressions * test(terminal): keep gate labels in valid expect shape * test(terminal): expose the surviving sub-threshold control-density case The only adverse strip fixture sat at 50% control density, which is exactly where the fallback fires and wins. A shape at 31 controls per 64-unit block evades the trigger and still loses to the per-character legacy (0.67x), so the benchmark structurally could not show it. Add that fixture, pin both density literals in the staleness guard so a retune fails loudly instead of silently measuring a boundary that moved, and export the probe constant the equivalence test was hardcoding. --- .../terminal-control-strip-benchmark.mjs | 194 +++++++++++++++--- .../terminal-output-frame-chunk-benchmark.mjs | 104 +++++++++- ...al-output-frame-chunks-equivalence.test.ts | 61 +++++- .../rpc/terminal-output-frame-chunks.ts | 32 ++- src/shared/command-code-output-status.test.ts | 117 ++++++++++- src/shared/command-code-output-status.ts | 48 +---- src/shared/terminal-control-stripping.ts | 73 +++++++ 7 files changed, 533 insertions(+), 96 deletions(-) create mode 100644 src/shared/terminal-control-stripping.ts diff --git a/config/scripts/terminal-control-strip-benchmark.mjs b/config/scripts/terminal-control-strip-benchmark.mjs index c06eb2a7ff0..52c563050cf 100644 --- a/config/scripts/terminal-control-strip-benchmark.mjs +++ b/config/scripts/terminal-control-strip-benchmark.mjs @@ -1,6 +1,54 @@ #!/usr/bin/env node -// Mirrors the complete pre/post stripTerminalControl paths at their production size caps. +// Compares legacy, slice-run, and production-adaptive control stripping. +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import nodeModule from 'node:module' import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +if (!process.execArgv.includes('--experimental-transform-types')) { + const result = spawnSync( + process.execPath, + ['--experimental-transform-types', '--no-warnings', import.meta.filename], + { stdio: 'inherit' } + ) + process.exit(result.status ?? 1) +} + +nodeModule.registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier) && context.parentURL) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(fileURLToPath(candidate))) { + return { url: candidate.href, shortCircuit: true } + } + } + return nextResolve(specifier, context) + } +}) + +const PRODUCTION_SOURCE = readFileSync( + new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url), + 'utf8' +) +for (const marker of [ + 'export function stripTerminalControl(data: string): string', + 'strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT', + 'output += withoutAnsi.slice(runStart, index)', + // Retuning either density constant changes which fixtures sit above/below the trigger, so + // pin the literals rather than the names: a silent retune would leave the sub-threshold + // fixture measuring a boundary that no longer exists. + 'const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64', + 'const CONTROL_DENSITY_FALLBACK_COUNT = 32' +]) { + if (!PRODUCTION_SOURCE.includes(marker)) { + throw new Error(`terminal-control-stripping.ts no longer contains \`${marker}\``) + } +} + +const { stripTerminalControl: stripAdaptive } = await import( + new URL('../../src/shared/terminal-control-stripping.ts', import.meta.url).href +) const ESC = String.fromCharCode(0x1b) const BEL = String.fromCharCode(0x07) @@ -15,6 +63,9 @@ const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( const HISTORY_LIMIT = 300 const SCAN_LIMIT = 4096 const SAMPLE_ID_LENGTH = 24 +// Mirrors terminal-control-stripping.ts; the marker guard above fails if either is retuned. +const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64 +const CONTROL_DENSITY_FALLBACK_COUNT = 32 const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501') let resultChecksum = 0 let validatedPairs = 0 @@ -75,6 +126,25 @@ function stripSliceRuns(data) { return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) } +function adaptiveFallbackIndex(data) { + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + let strippedInBlock = 0 + let blockEnd = 64 + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (index === blockEnd) { + strippedInBlock = 0 + blockEnd += 64 + } + if (isStrippedCode(withoutAnsi.charCodeAt(index))) { + strippedInBlock += 1 + if (strippedInBlock === 32) { + return index + } + } + } + return -1 +} + function fixedSampleId(sampleId) { return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH) } @@ -95,6 +165,26 @@ function makeTuiFixture(length, sampleId, strippedControl) { return text + 'x'.repeat(length - text.length) } +// 31 controls per 64-unit block: one below the fallback trigger, so the adaptive path keeps +// slice-run bookkeeping on a shape dense enough to lose to the per-character legacy. This is the +// worst surviving case; it exists so the narrowed adverse window stays visible instead of hiding +// behind the 50% fixture, where the fallback fires and wins. +function makeSubThresholdDenseFixture(length, sampleId, strippedControl) { + const id = fixedSampleId(sampleId) + const units = [] + for (let index = 0; index < length; index += 1) { + const blockOffset = index % CONTROL_DENSITY_BLOCK_CODE_UNITS + if (index < id.length) { + units.push(id[index]) + } else if (blockOffset % 2 === 1 && blockOffset < (CONTROL_DENSITY_FALLBACK_COUNT - 1) * 2) { + units.push(strippedControl) + } else { + units.push(String.fromCharCode(97 + (index % 26))) + } + } + return units.join('') +} + function makeDenseFixture(length, sampleId, strippedControl) { const prefix = `\x1b[35m${fixedSampleId(sampleId)}` const suffix = '\x1b[0m' @@ -120,33 +210,35 @@ function consumeOutput(output) { resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2)) } -function recordPair(fixture, sampleId, perCharFirst, samples) { - const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02') - const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01') - if ( - perCharFixture === sliceRunsFixture || - perCharFixture.length !== fixture.length || - sliceRunsFixture.length !== fixture.length - ) { +const IMPLEMENTATIONS = [ + ['perChar', stripPerChar, '\x01'], + ['sliceRuns', stripSliceRuns, '\x02'], + ['adaptive', stripAdaptive, '\x03'] +] + +function recordRotation(fixture, sampleId, lead, samples) { + const inputs = IMPLEMENTATIONS.map(([name, strip, control]) => ({ + name, + strip, + input: fixture.make(sampleId, control) + })) + if (inputs.some(({ input }) => input.length !== fixture.length)) { throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`) } - let perCharResult - let sliceRunsResult - if (perCharFirst) { - perCharResult = measure(stripPerChar, perCharFixture) - sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) - } else { - sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) - perCharResult = measure(stripPerChar, perCharFixture) + const results = new Map() + for (let offset = 0; offset < inputs.length; offset += 1) { + const entry = inputs[(lead + offset) % inputs.length] + results.set(entry.name, measure(entry.strip, entry.input)) } - if (perCharResult.output !== sliceRunsResult.output) { + const outputs = [...results.values()].map(({ output }) => output) + if (new Set(outputs).size !== 1) { throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`) } - consumeOutput(perCharResult.output) - consumeOutput(sliceRunsResult.output) + for (const [name, result] of results) { + consumeOutput(result.output) + samples[name].push(result.elapsed) + } validatedPairs += 1 - samples.perChar.push(perCharResult.elapsed) - samples.sliceRuns.push(sliceRunsResult.elapsed) } const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length @@ -171,32 +263,68 @@ const fixtures = [ label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`, length: SCAN_LIMIT, make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control) + }, + { + label: `${SCAN_LIMIT} scan 31/block C0`, + length: SCAN_LIMIT, + make: (sampleId, control) => makeSubThresholdDenseFixture(SCAN_LIMIT, sampleId, control) } ] +const selectorFixtures = [ + { label: '31 controls', data: `${'\x01'.repeat(31)}${'a'.repeat(33)}`, expected: -1 }, + { label: '32 controls', data: `${'\x01'.repeat(32)}${'a'.repeat(32)}`, expected: 31 }, + { + label: 'block reset at 64', + data: `${'\x01'.repeat(31)}${'a'.repeat(33)}${'\x01'.repeat(32)}`, + expected: 95 + }, + { + label: 'late dense block', + data: `${'a'.repeat(64 * 3)}${'\x01'.repeat(32)}tail`, + expected: 223 + }, + { + label: 'routine TUI', + data: makeTuiFixture(SCAN_LIMIT, 'selector', '\x01'), + expected: -1 + }, + { + label: '31/block never triggers', + data: makeSubThresholdDenseFixture(SCAN_LIMIT, 'selector', '\x01'), + expected: -1 + } +] +for (const fixture of selectorFixtures) { + const actual = adaptiveFallbackIndex(fixture.data) + if (actual !== fixture.expected) { + throw new Error(`${fixture.label} fallback index ${actual}, expected ${fixture.expected}`) + } +} + const pad = (value, width) => String(value).padStart(width) console.log('Complete stripTerminalControl path. Lower is better.') +console.log(`iterations=${ITERATIONS} (${ITERATIONS * 3} rotated samples/implementation, median)`) console.log( - `iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)` -) -console.log( - `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}` + `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('adaptive', 11)} ${pad('vs legacy', 10)} ${pad('vs slice', 9)}` ) for (const fixture of fixtures) { - const samples = { perChar: [], sliceRuns: [] } + const samples = { perChar: [], sliceRuns: [], adaptive: [] } for (let index = 0; index < ITERATIONS; index += 1) { - const orders = index % 2 === 0 ? [true, false] : [false, true] - for (const perCharFirst of orders) { - const orderLabel = perCharFirst ? 'per-first' : 'slice-first' - recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples) + for (let lead = 0; lead < IMPLEMENTATIONS.length; lead += 1) { + recordRotation(fixture, `${index}:lead-${lead}`, lead, samples) } } const perChar = median(samples.perChar) const sliceRuns = median(samples.sliceRuns) + const adaptive = median(samples.adaptive) console.log( - `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}` + `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(adaptive * 1000).toFixed(1)} us`, 11)} ${pad(`${(perChar / adaptive).toFixed(2)}x`, 10)} ${pad(`${(sliceRuns / adaptive).toFixed(2)}x`, 9)}` ) } -console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`) +console.log( + `\nvalidated=${validatedPairs} measured rotations, result checksum=${resultChecksum >>> 0}` +) +console.log(`selector checks=${selectorFixtures.length}`) console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.') diff --git a/config/scripts/terminal-output-frame-chunk-benchmark.mjs b/config/scripts/terminal-output-frame-chunk-benchmark.mjs index 6dd6364524c..c18d8eb2032 100644 --- a/config/scripts/terminal-output-frame-chunk-benchmark.mjs +++ b/config/scripts/terminal-output-frame-chunk-benchmark.mjs @@ -8,10 +8,8 @@ // `chunk += part`. The gate in front of it (terminalStreamByteLengthExceeds) ran the // same per-code-point walk over the whole payload a second time. // -// The fix: one charCodeAt scan computing the UTF-8 width inline with no per-code-point -// string, chunk text taken as data.slice(chunkStart, end), and a gate of -// `data.length > CHUNK || Buffer.byteLength(data, 'utf8') > CHUNK` -- sound because a -// string's UTF-8 length is never below its UTF-16 length. +// The fix: one charCodeAt scan computing UTF-8 width inline, slices for chunk text, +// and bounded byte probes when UTF-16 length alone cannot prove fit or overflow. // // BOTH arms run the complete production path, encodeTerminalStreamText included, and // their emitted frames (base64 + seq + opcode) are compared before any timing, so an @@ -48,11 +46,13 @@ nodeModule.registerHooks({ }) const ITERATIONS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ITERATIONS ?? '40') +const GATE_ITERATIONS = Number(process.env.ORCA_FRAME_GATE_BENCH_ITERATIONS ?? '2000') const WARMUP = Number(process.env.ORCA_FRAME_CHUNK_BENCH_WARMUP ?? '8') const ROUNDS = Number(process.env.ORCA_FRAME_CHUNK_BENCH_ROUNDS ?? '6') for (const [name, value] of [ ['ORCA_FRAME_CHUNK_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_FRAME_GATE_BENCH_ITERATIONS', GATE_ITERATIONS], ['ORCA_FRAME_CHUNK_BENCH_WARMUP', WARMUP], ['ORCA_FRAME_CHUNK_BENCH_ROUNDS', ROUNDS] ]) { @@ -73,12 +73,19 @@ const CLIPBOARD_SOURCE = readFileSync( 'utf8' ) -// Why re-read the sources: this benchmark's claim is that the OLD arm paid a -// measureClipboardTextByteLength call per code point and the NEW one pays a single -// Buffer.byteLength gate. Match the CALL form, not a bare word -- a comment naming -// the function would satisfy a substring check. +// Match executable source markers so a stale benchmark fails instead of misleading. for (const [source, label, marker] of [ - [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', "Buffer.byteLength(data, 'utf8')"], + [ + CHUNK_SOURCE, + 'terminal-output-frame-chunks.ts', + 'export function exceedsTerminalStreamChunkBytes(data: string): boolean' + ], + [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS'], + [ + CHUNK_SOURCE, + 'terminal-output-frame-chunks.ts', + 'terminalStreamByteLength(data.slice(start, end))' + ], [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'const text = data.slice(chunkStart, end)'], [CHUNK_SOURCE, 'terminal-output-frame-chunks.ts', 'data.charCodeAt(index + 1)'], [CLIPBOARD_SOURCE, 'clipboard-text.ts', 'export function measureClipboardTextByteLength('], @@ -103,10 +110,17 @@ const { measureClipboardTextByteLength } = await import( const { TerminalStreamOpcode, encodeTerminalStreamJson, encodeTerminalStreamText } = await import( new URL('../../src/shared/terminal-stream-protocol.ts', import.meta.url).href ) -const { iterateTerminalOutputFrameChunks } = await import( +const { exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks } = await import( new URL('../../src/main/runtime/rpc/terminal-output-frame-chunks.ts', import.meta.url).href ) +function previousGate(data) { + return ( + data.length > TERMINAL_STREAM_CHUNK_BYTES || + Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES + ) +} + // Pre-fix arm: the exact code that shipped, including the second full walk in the gate. function* iterateBefore(data, meta) { const rawLength = meta?.rawLength ?? data.length @@ -260,6 +274,11 @@ const fixtures = [ data: repeatTo(LONE_HIGH, 64 * 1024), meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) }, + { + label: 'late-wide gate miss (3 chunks)', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}`, + meta: (data) => ({ seq: 5_000_000, rawLength: data.length }) + }, { label: 'ascii 512KiB (snapshot chunking)', data: 'x'.repeat(512 * 1024), @@ -309,6 +328,38 @@ function measureInterleaved(data, meta) { return { beforeMs: median(beforeSamples), afterMs: median(afterSamples) } } +let gateChecksum = 0 + +function drainGate(gate, data) { + gateChecksum = Math.imul(gateChecksum ^ (gate(data) ? 1 : 0), 16777619) >>> 0 +} + +function measureGateInterleaved(data) { + for (let index = 0; index < WARMUP * 10; index += 1) { + drainGate(previousGate, data) + drainGate(exceedsTerminalStreamChunkBytes, data) + } + const previousSamples = [] + const boundedSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + const run = (gate, samples) => { + const start = performance.now() + for (let index = 0; index < GATE_ITERATIONS; index += 1) { + drainGate(gate, data) + } + samples.push((performance.now() - start) / GATE_ITERATIONS) + } + if (round % 2 === 0) { + run(previousGate, previousSamples) + run(exceedsTerminalStreamChunkBytes, boundedSamples) + } else { + run(exceedsTerminalStreamChunkBytes, boundedSamples) + run(previousGate, previousSamples) + } + } + return { previousMs: median(previousSamples), boundedMs: median(boundedSamples) } +} + const pad = (value, width) => String(value).padStart(width) console.log('iterateTerminalOutputFrameChunks, per flushed batch. Lower is better.') console.log( @@ -342,6 +393,39 @@ for (const fixture of fixtures) { console.log( `\nvalidated=${comparedFixtures} fixtures frame-identical before timing, checksum=${frameChecksum >>> 0}` ) +const gateFixtures = [ + { label: 'ascii 4KiB fit proof', data: 'x'.repeat(4 * 1024) }, + { + label: 'three-byte exact-cap fit proof', + data: '\u20ac'.repeat(TERMINAL_STREAM_CHUNK_BYTES / 3) + }, + { + label: 'late-wide fit after probes', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(11_000)}` + }, + { + label: 'late-wide miss during probes', + data: `${'a'.repeat(16_000)}${'\u20ac'.repeat(32_000)}` + }, + { label: 'ascii 64KiB overflow proof', data: 'x'.repeat(64 * 1024) } +] + +console.log('\nTerminal frame-fit gate only. Lower is better.') +console.log( + `${pad('fixture', 34)} ${pad('result', 8)} ${pad('whole scan', 12)} ${pad('bounded', 11)} ${pad('speedup', 9)}` +) +for (const fixture of gateFixtures) { + const previous = previousGate(fixture.data) + const bounded = exceedsTerminalStreamChunkBytes(fixture.data) + if (previous !== bounded) { + throw new Error(`gate result differs for ${fixture.label}`) + } + const { previousMs, boundedMs } = measureGateInterleaved(fixture.data) + console.log( + `${pad(fixture.label, 34)} ${pad(bounded ? 'miss' : 'fit', 8)} ${pad(`${(previousMs * 1000).toFixed(2)} us`, 12)} ${pad(`${(boundedMs * 1000).toFixed(2)} us`, 11)} ${pad(boundedMs > 0 ? `${(previousMs / boundedMs).toFixed(2)}x` : 'n/a', 9)}` + ) +} +console.log(`gate fixtures=${gateFixtures.length}, checksum=${gateChecksum >>> 0}`) console.log( 'Every byte of remote terminal output crosses this function; the batcher flushes at\nTERMINAL_OUTPUT_BATCH_MAX_BYTES (64 KiB) or every 5 ms, so a busy remote agent pane\nruns it tens of times a second per subscribed stream.' ) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index f43f7cb93fa..538e700e7a8 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -7,6 +7,8 @@ import { import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' import { measureClipboardTextByteLength } from '../../../shared/clipboard-text' import { + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, + exceedsTerminalStreamChunkBytes, iterateTerminalOutputFrameChunks, type TerminalOutputFrameChunk, type TerminalOutputMeta @@ -23,6 +25,19 @@ function legacyByteLengthExceeds(data: string, maxBytes: number): boolean { return measureClipboardTextByteLength(data, { stopAfterBytes: maxBytes }).exceededLimit } +function expectGateEquivalent(data: string, label: string): void { + expect({ label, result: exceedsTerminalStreamChunkBytes(data) }).toEqual({ + label, + result: legacyByteLengthExceeds(data, TERMINAL_STREAM_CHUNK_BYTES) + }) +} + +function makeExactByteLength(unit: string, byteLength: number): string { + const unitBytes = Buffer.byteLength(unit, 'utf8') + const repeats = Math.floor(byteLength / unitBytes) + return unit.repeat(repeats) + 'a'.repeat(byteLength - repeats * unitBytes) +} + function* legacyIterateTerminalOutputFrameChunks( data: string, meta?: TerminalOutputMeta @@ -216,6 +231,50 @@ function randomText(random: () => number, parts: number): string { } describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization loop', () => { + it('matches the legacy gate at the byte cap ±3 for every UTF-8 shape', () => { + for (const [label, unit] of [ + ['ascii', 'a'], + ['two-byte', '\u00e9'], + ['three-byte', '\u20ac'], + ['astral', SURROGATE_PAIR], + ['lone-high', LONE_HIGH], + ['lone-low', LONE_LOW], + ['reversed-surrogates', `${LONE_LOW}${LONE_HIGH}a`] + ] as const) { + for (let delta = -3; delta <= 3; delta += 1) { + const byteLength = TERMINAL_STREAM_CHUNK_BYTES + delta + const data = makeExactByteLength(unit, byteLength) + expect(Buffer.byteLength(data, 'utf8')).toBe(byteLength) + expectGateEquivalent(data, `${label} delta=${delta}`) + } + } + }) + + it('matches when probe boundaries bisect or surround surrogate pairs', () => { + const probe = TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS + for (const offset of [-2, -1, 0, 1, 2]) { + const pairStart = probe + offset + const prefix = 'a'.repeat(pairStart) + const suffix = '\u20ac'.repeat(12_000) + for (const middle of [SURROGATE_PAIR, LONE_HIGH, LONE_LOW, LONE_LOW + LONE_HIGH]) { + const data = prefix + middle + suffix + expectGateEquivalent(data, `probe offset=${offset} middle=${escapeUnits(middle)}`) + expectEquivalent(data, undefined, `probe frames offset=${offset}`) + } + } + }) + + it('stops correctly when late wide text crosses the cap', () => { + const asciiPrefix = 'a'.repeat(16_000) + for (const wide of ['\u00e9', '\u20ac', SURROGATE_PAIR, LONE_HIGH]) { + for (const wideParts of [8_000, 12_000, 16_000]) { + const data = asciiPrefix + wide.repeat(wideParts) + expectGateEquivalent(data, `late-wide ${escapeUnits(wide)} parts=${wideParts}`) + expectEquivalent(data, undefined, `late-wide frames ${escapeUnits(wide)}`) + } + } + }) + it('matches on the small/no-chunking sizes', () => { for (const size of [0, 1, 2, 3, 7, 64, 1024, TERMINAL_STREAM_CHUNK_BYTES - 1]) { sweepAll('a'.repeat(size), `ascii ${size}`) @@ -416,7 +475,7 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) } - }) + }, 15_000) it('keeps every emitted frame within the wire cap and reassembles to the input', () => { const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}` diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks.ts b/src/main/runtime/rpc/terminal-output-frame-chunks.ts index f040934a565..167b6c20494 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks.ts @@ -4,6 +4,7 @@ import { encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { TERMINAL_STREAM_CHUNK_BYTES } from '../../../shared/terminal-multiplex-flow-control' +import { terminalStreamByteLength } from './terminal-stream-byte-length' export type TerminalOutputMeta = { seq?: number @@ -18,12 +19,31 @@ export type TerminalOutputFrameChunk = { opcode?: TerminalStreamOpcode } -// Why: UTF-8 length is never below UTF-16 length, so a code-unit count over the cap already proves the byte count is. -function exceedsTerminalStreamChunkBytes(data: string): boolean { - return ( - data.length > TERMINAL_STREAM_CHUNK_BYTES || - Buffer.byteLength(data, 'utf8') > TERMINAL_STREAM_CHUNK_BYTES - ) +export const TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS = 8 * 1024 +const MAX_UTF8_BYTES_PER_CODE_UNIT = 3 + +export function exceedsTerminalStreamChunkBytes(data: string): boolean { + if (data.length > TERMINAL_STREAM_CHUNK_BYTES) { + return true + } + if (data.length * MAX_UTF8_BYTES_PER_CODE_UNIT <= TERMINAL_STREAM_CHUNK_BYTES) { + return false + } + let byteLength = 0 + for (let start = 0; start < data.length; ) { + let end = Math.min(start + TERMINAL_STREAM_BYTE_PROBE_CODE_UNITS, data.length) + const high = data.charCodeAt(end - 1) + const low = data.charCodeAt(end) + if (end < data.length && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) { + end -= 1 + } + byteLength += terminalStreamByteLength(data.slice(start, end)) + if (byteLength > TERMINAL_STREAM_CHUNK_BYTES) { + return true + } + start = end + } + return false } export function* iterateTerminalOutputFrameChunks( diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index e19504fc5ed..a86c89a174b 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -1,5 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { + createCommandCodeOutputStatusDetector, + stripTerminalControl +} from './command-code-output-status' afterEach(() => { vi.restoreAllMocks() @@ -317,6 +320,44 @@ function maxStringContextLength(contexts: unknown[]): number { } describe('terminal control stripping', () => { + const esc = String.fromCharCode(0x1b) + const bel = String.fromCharCode(0x07) + const ansiEscape = new RegExp( + `${esc}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${bel}]*(?:${bel}|${esc}\\\\))`, + 'g' + ) + const incompleteAnsiEscape = new RegExp( + `${esc}(?:\\[[0-?]*[ -/]*|\\][^${bel}${esc}]*|\\S?)?$`, + 'g' + ) + + function legacyStripTerminalControl(data: string): string { + const withoutAnsi = data.replace(ansiEscape, '').replace(incompleteAnsiEscape, '') + let output = '' + for (let index = 0; index < withoutAnsi.length; index += 1) { + const code = withoutAnsi.charCodeAt(index) + if ((code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)) { + continue + } + output += withoutAnsi[index] + } + return output + } + + function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return (state >>> 0) / 0x1_0000_0000 + } + } + + function expectLegacyEquivalent(data: string): void { + expect(stripTerminalControl(data)).toBe(legacyStripTerminalControl(data)) + } + function promptFrom(raw: string): string | null { let captured: string | null = null const detector = createCommandCodeOutputStatusDetector({ @@ -347,4 +388,78 @@ describe('terminal control stripping', () => { '日本語 \u{1f389} prompt' ) }) + + it('matches the legacy filter across ANSI, C0/C1, line breaks, and Unicode', () => { + for (const data of [ + '', + 'plain text', + '\x1b[35mstyled\x1b[0m', + '\x1b[unterminated', + '\x00leading', + 'trailing\x9f', + '\x01\x02adjacent\x7f\x80', + 'keep\r\nline breaks', + '日本語 \u{1f389} \ud83d \ude00', + '\x1b]0;title\x07prompt', + '\x1b]0;title\x1b\\prompt' + ]) { + expectLegacyEquivalent(data) + } + }) + + it('matches legacy output at density thresholds and block resets', () => { + const control = '\x01' + const fixtures = [ + `${control.repeat(31)}${'a'.repeat(33)}`, + `${control.repeat(32)}${'a'.repeat(32)}`, + `${'a'.repeat(32)}${control.repeat(31)}a`, + `${control.repeat(31)}${'a'.repeat(33)}${control.repeat(31)}z`, + `${control.repeat(31)}${'a'.repeat(33)}${control.repeat(32)}z`, + `${'a'.repeat(64 * 3)}${control.repeat(32)}tail`, + `${'a\x01'.repeat(2048)}tail` + ] + for (const data of fixtures) { + expectLegacyEquivalent(data) + } + }) + + it('exhaustively matches short strings over control and Unicode code units', () => { + const alphabet = ['a', '\r', '\n', '\x01', '\x7f', '\x80', '\u20ac', '\ud83d'] + for (let encoded = 0; encoded < alphabet.length ** 4; encoded += 1) { + let cursor = encoded + let data = '' + for (let position = 0; position < 4; position += 1) { + data += alphabet[cursor % alphabet.length] + cursor = Math.floor(cursor / alphabet.length) + } + expectLegacyEquivalent(data) + } + }) + + it('matches seeded random terminal text', () => { + const random = makeRandom(0xc0de_0727) + const alphabet = [ + 'a', + 'Z', + '\r', + '\n', + '\x00', + '\x1f', + '\x7f', + '\x9f', + '\u00e9', + '\u20ac', + '\u{1f389}', + '\x1b[35m', + '\x1b[0m' + ] + for (let trial = 0; trial < 2_000; trial += 1) { + let data = '' + const parts = Math.floor(random() * 256) + for (let index = 0; index < parts; index += 1) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + expectLegacyEquivalent(data) + } + }) }) diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 70bb9ff039e..889c9a787e0 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -9,21 +9,14 @@ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate } from './command-code-prompt-text' +import { stripTerminalControl } from './terminal-control-stripping' + +export { stripTerminalControl } from './terminal-control-stripping' type CommandCodeOutputStatusDetector = { observe: (data: string) => boolean } -const ESC = String.fromCharCode(0x1b) -const BEL = String.fromCharCode(0x07) -const ANSI_ESCAPE_RE = new RegExp( - `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, - 'g' -) -const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( - `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, - 'g' -) const RECENT_TEXT_LIMIT = 300 const STATUS_SCAN_TEXT_LIMIT = 4096 const COMMAND_CODE_STATUS_GLYPH_RE_SOURCE = '[·○◇☆✧⌘✻⎿]' @@ -121,41 +114,6 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp( const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./ const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/ -function stripTerminalControl(data: string): string { - if (!terminalControlMayAffectText(data)) { - return data - } - const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') - // Four calls per PTY chunk favor copying sparse intact runs over per-character concatenation. - let output = '' - let runStart = 0 - for (let index = 0; index < withoutAnsi.length; index += 1) { - const code = withoutAnsi.charCodeAt(index) - if ((code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)) { - if (index > runStart) { - output += withoutAnsi.slice(runStart, index) - } - runStart = index + 1 - } - } - return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) -} - -function terminalControlMayAffectText(data: string): boolean { - for (let index = 0; index < data.length; index += 1) { - const code = data.charCodeAt(index) - if ( - code === 0x0d || - code === 0x1b || - (code <= 0x1f && code !== 0x0a) || - (code >= 0x7f && code <= 0x9f) - ) { - return true - } - } - return false -} - function cleanPromptCandidate(value: string): string { return cleanCommandCodePromptCandidate(stripTerminalControl(value)) } diff --git a/src/shared/terminal-control-stripping.ts b/src/shared/terminal-control-stripping.ts new file mode 100644 index 00000000000..2f843e92630 --- /dev/null +++ b/src/shared/terminal-control-stripping.ts @@ -0,0 +1,73 @@ +const ESC = String.fromCharCode(0x1b) +const BEL = String.fromCharCode(0x07) +const ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, + 'g' +) +const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, + 'g' +) +const CONTROL_DENSITY_BLOCK_CODE_UNITS = 64 +const CONTROL_DENSITY_FALLBACK_COUNT = 32 + +function isStrippedTerminalControl(code: number): boolean { + return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f) +} + +export function stripTerminalControl(data: string): string { + if (!terminalControlMayAffectText(data)) { + return data + } + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + // Four calls per PTY chunk favor copying sparse intact runs over per-character concatenation. + let output = '' + let runStart = 0 + let strippedInBlock = 0 + let blockEnd = CONTROL_DENSITY_BLOCK_CODE_UNITS + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (index === blockEnd) { + strippedInBlock = 0 + blockEnd += CONTROL_DENSITY_BLOCK_CODE_UNITS + } + if (isStrippedTerminalControl(withoutAnsi.charCodeAt(index))) { + if (index > runStart) { + output += withoutAnsi.slice(runStart, index) + } + runStart = index + 1 + strippedInBlock += 1 + if (strippedInBlock === CONTROL_DENSITY_FALLBACK_COUNT) { + let tailOutput = '' + for (let tailIndex = runStart; tailIndex < withoutAnsi.length; tailIndex += 1) { + const tailCode = withoutAnsi.charCodeAt(tailIndex) + // Inlined isStrippedTerminalControl: this tail runs per code unit on the shape that + // already lost to the call overhead. Keep the two copies in sync. + if ( + (tailCode <= 0x1f && tailCode !== 0x0a && tailCode !== 0x0d) || + (tailCode >= 0x7f && tailCode <= 0x9f) + ) { + continue + } + tailOutput += withoutAnsi[tailIndex] + } + return output + tailOutput + } + } + } + return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) +} + +function terminalControlMayAffectText(data: string): boolean { + for (let index = 0; index < data.length; index += 1) { + const code = data.charCodeAt(index) + if ( + code === 0x0d || + code === 0x1b || + (code <= 0x1f && code !== 0x0a) || + (code >= 0x7f && code <= 0x9f) + ) { + return true + } + } + return false +} From 54ed8c23110a65ba2bcc1e0154d750d3132ce834 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:34:59 -0700 Subject: [PATCH 16/53] fix(opencode): harden lifecycle status delivery (#11017) --- .../hook-plugin-child-attention.test.ts | 610 ++++++++++++++ .../hook-plugin-fail-open-ownership.test.ts | 439 ++++++++++ .../hook-plugin-lifecycle-delivery.test.ts | 765 ++++++++++++++++++ .../hook-plugin-message-part-throttle.test.ts | 76 ++ src/main/opencode/hook-service.test.ts | 38 +- src/main/opencode/hook-service.ts | 753 +++++++++++++++-- 6 files changed, 2607 insertions(+), 74 deletions(-) create mode 100644 src/main/opencode/hook-plugin-child-attention.test.ts create mode 100644 src/main/opencode/hook-plugin-fail-open-ownership.test.ts create mode 100644 src/main/opencode/hook-plugin-lifecycle-delivery.test.ts diff --git a/src/main/opencode/hook-plugin-child-attention.test.ts b/src/main/opencode/hook-plugin-child-attention.test.ts new file mode 100644 index 00000000000..058daf2ea1b --- /dev/null +++ b/src/main/opencode/hook-plugin-child-attention.test.ts @@ -0,0 +1,610 @@ +/** + * Executes the generated OpenCode plugin artifact to verify that descendant + * blockers roll up to their root pane without giving child lifecycle or + * message events authority over that pane. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type SessionFixture = { id: string; parentID?: string } +type PluginEvent = { type: string; properties?: Record } +type PluginEventHandler = (input: { event: PluginEvent }) => Promise +type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } +type PluginFactory = (ctx: unknown) => Promise +type SessionList = ( + parameters?: { signal?: AbortSignal }, + options?: { signal?: AbortSignal } +) => Promise<{ data: SessionFixture[] }> +type RecordedPost = { + hook_event_name: string + id?: string + sessionID?: string +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +describe('OpenCode plugin child attention', () => { + let tempDir: string + let posts: RecordedPost[] + let savedEnv: Record + let savedFetch: typeof globalThis.fetch + let pluginFactory: PluginFactory | undefined + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-child-attention-')) + posts = [] + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + pluginFactory = undefined + savedFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { payload: RecordedPost } + posts.push(body.payload) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + async function createHooks( + sessions: SessionFixture[], + list: SessionList = async () => ({ + data: sessions + }) + ): Promise { + if (!pluginFactory) { + const pluginPath = join(tempDir, 'orca-opencode-status.mjs') + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + const module = (await import(pathToFileURL(pluginPath).href)) as { + OrcaOpenCodeStatusPlugin: PluginFactory + } + pluginFactory = module.OrcaOpenCodeStatusPlugin + } + return pluginFactory({ + client: { + session: { + list + } + } + }) + } + + function status(type: 'busy' | 'idle', sessionID: string): PluginEvent { + return { + type: 'session.status', + properties: { sessionID, status: { type } } + } + } + + function attention( + type: 'permission.asked' | 'question.asked', + id: string, + sessionID: string, + tool?: { messageID: string; callID: string } + ): PluginEvent { + return { + type, + properties: { id, sessionID, ...(tool ? { tool } : {}) } + } + } + + function resolution( + type: 'permission.replied' | 'question.replied' | 'question.rejected', + requestID: string, + sessionID: string + ): PluginEvent { + return { + type, + properties: { requestID, sessionID } + } + } + + function completedQuestion( + sessionID: string, + messageID: string, + callID: string, + status: 'completed' | 'error' = 'completed' + ): PluginEvent { + return { + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: 'tool', + tool: 'question', + messageID, + callID, + state: { status } + } + } + } + } + + function names(): string[] { + return posts.map((post) => post.hook_event_name) + } + + it.each([ + ['permission.asked', 'PermissionRequest'], + ['question.asked', 'AskUserQuestion'] + ] as const)('attributes nested child %s to the root pane', async (type, expectedName) => { + const hooks = await createHooks([ + { id: 'root' }, + { id: 'child', parentID: 'root' }, + { id: 'grandchild', parentID: 'child' } + ]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: attention(type, 'request-1', 'grandchild') }) + + expect(posts.at(-1)).toMatchObject({ + hook_event_name: expectedName, + id: 'request-1', + sessionID: 'root' + }) + }) + + it('clears only the matching child reply or rejection and preserves root Busy', async () => { + const hooks = await createHooks([ + { id: 'root' }, + { id: 'child-a', parentID: 'root' }, + { id: 'child-b', parentID: 'root' } + ]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: attention('question.asked', 'question-a', 'child-a') }) + const waitingPosts = posts.length + await hooks.event({ event: status('busy', 'root') }) + expect(posts).toHaveLength(waitingPosts) + + await hooks.event({ event: attention('permission.asked', 'permission-b', 'child-b') }) + await hooks.event({ + event: resolution('permission.replied', 'permission-b', 'child-a') + }) + expect(posts.at(-1)?.hook_event_name).toBe('PermissionRequest') + + await hooks.event({ + event: resolution('permission.replied', 'permission-b', 'child-b') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'question-a', + sessionID: 'root' + }) + + await hooks.event({ + event: resolution('question.rejected', 'question-a', 'child-a') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionBusy', + sessionID: 'root' + }) + + await hooks.event({ event: status('idle', 'root') }) + expect(posts.at(-1)?.hook_event_name).toBe('SessionIdle') + }) + + it.each(['completed', 'error'] as const)( + 'clears only the matching child question when its tool is %s', + async (completionStatus) => { + const hooks = await createHooks([ + { id: 'root' }, + { id: 'child-a', parentID: 'root' }, + { id: 'child-b', parentID: 'root' } + ]) + const toolA = { messageID: 'message-a', callID: 'call-a' } + const toolB = { messageID: 'message-b', callID: 'call-b' } + + await hooks.event({ + event: attention('question.asked', 'question-a', 'child-a', toolA) + }) + await hooks.event({ + event: attention('question.asked', 'question-b', 'child-b', toolB) + }) + const waitingPosts = posts.length + await hooks.event({ + event: completedQuestion('child-a', toolB.messageID, toolB.callID, completionStatus) + }) + await hooks.event({ + event: completedQuestion('child-b', toolB.messageID, 'wrong-call', completionStatus) + }) + expect(posts).toHaveLength(waitingPosts) + + await hooks.event({ + event: completedQuestion('child-b', toolB.messageID, toolB.callID, completionStatus) + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'question-a', + sessionID: 'root' + }) + + await hooks.event({ + event: completedQuestion('child-a', toolA.messageID, toolA.callID, completionStatus) + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionIdle', + sessionID: 'root' + }) + } + ) + + it('uses child Idle only to clean that child blocker, then restores root Busy', async () => { + const hooks = await createHooks([{ id: 'root' }, { id: 'child', parentID: 'root' }]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: attention('question.asked', 'question-child', 'child') }) + await hooks.event({ event: status('idle', 'child') }) + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + }) + + it('lets only matching unknown Idle retire attention across cyclic ancestry', async () => { + const list = vi.fn(async () => ({ + data: [ + { id: 'child-a', parentID: 'child-b' }, + { id: 'child-b', parentID: 'child-a' } + ] + })) + const hooks = await createHooks([], list) + + await hooks.event({ + event: attention('question.asked', 'cyclic-question', 'child-a') + }) + await hooks.event({ event: status('idle', 'child-b') }) + expect(names()).toEqual(['AskUserQuestion']) + + await hooks.event({ event: status('idle', 'child-a') }) + + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + expect(posts[0]).toMatchObject({ + id: 'cyclic-question', + sessionID: 'child-a' + }) + expect(posts.at(-1)?.sessionID).toBe('child-a') + expect(list).toHaveBeenCalledTimes(6) + }) + + it('matches a child reply after its ancestry lookup fails', async () => { + let lookupFails = false + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: [{ id: 'root' }] } + }) + const hooks = await createHooks([], list) + + await hooks.event({ event: status('busy', 'root') }) + lookupFails = true + await hooks.event({ + event: attention('permission.asked', 'permission-child', 'child') + }) + await hooks.event({ + event: resolution('permission.replied', 'permission-child', 'child') + }) + + expect(names()).toEqual(['SessionBusy', 'PermissionRequest', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + }) + + it('clears timed-out child attention without repeating its ancestry lookup', async () => { + vi.useFakeTimers() + let lookupHangs = false + const list = vi.fn( + async (options?: { signal?: AbortSignal }): Promise<{ data: SessionFixture[] }> => { + if (!lookupHangs) { + return { data: [{ id: 'root' }] } + } + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + } + ) + const hooks = await createHooks([], list) + await hooks.event({ event: status('busy', 'root') }) + lookupHangs = true + + const asked = hooks.event({ + event: attention('question.asked', 'question-child', 'child') + }) + await vi.advanceTimersByTimeAsync(2_000) + await asked + const lookupCountAfterAsk = list.mock.calls.length + const replied = hooks.event({ + event: resolution('question.replied', 'question-child', 'child') + }) + await replied + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + expect(list).toHaveBeenCalledTimes(lookupCountAfterAsk) + }) + + it('clears timed-out child attention on Idle without making unknown Idle authoritative', async () => { + vi.useFakeTimers() + let lookupHangs = false + const list = vi.fn( + async (options?: { signal?: AbortSignal }): Promise<{ data: SessionFixture[] }> => { + if (!lookupHangs) { + return { data: [{ id: 'root' }] } + } + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + } + ) + const hooks = await createHooks([], list) + await hooks.event({ event: status('busy', 'root') }) + lookupHangs = true + + const asked = hooks.event({ + event: attention('question.asked', 'question-child', 'child') + }) + await vi.advanceTimersByTimeAsync(2_000) + await asked + const idle = hooks.event({ event: status('idle', 'child') }) + await vi.advanceTimersByTimeAsync(2_000) + await idle + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('root') + }) + + it('delivers same-id child blockers across factories when the older owner resolves first', async () => { + const first = await createHooks([{ id: 'root-a' }, { id: 'child-a', parentID: 'root-a' }]) + const second = await createHooks([{ id: 'root-b' }, { id: 'child-b', parentID: 'root-b' }]) + + await first.event({ event: status('busy', 'root-a') }) + await second.event({ event: status('busy', 'root-b') }) + await first.event({ event: attention('question.asked', 'same-id', 'child-a') }) + await second.event({ event: attention('question.asked', 'same-id', 'child-b') }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'same-id', + sessionID: 'root-b' + }) + const postsAfterSecondBlocker = posts.length + + await first.event({ + event: resolution('question.replied', 'same-id', 'child-a') + }) + expect(posts).toHaveLength(postsAfterSecondBlocker) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'same-id', + sessionID: 'root-b' + }) + + await second.event({ + event: resolution('question.replied', 'same-id', 'child-b') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionBusy', + sessionID: 'root-b' + }) + }) + + it('keeps same-id blockers from separate sessions in one factory independent', async () => { + const hooks = await createHooks([ + { id: 'root-a' }, + { id: 'child-a', parentID: 'root-a' }, + { id: 'root-b' }, + { id: 'child-b', parentID: 'root-b' } + ]) + + await hooks.event({ event: status('busy', 'root-a') }) + await hooks.event({ event: status('busy', 'root-b') }) + await hooks.event({ event: attention('question.asked', 'same-id', 'child-a') }) + await hooks.event({ event: attention('question.asked', 'same-id', 'child-b') }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + sessionID: 'root-b' + }) + + await hooks.event({ + event: resolution('question.replied', 'same-id', 'child-a') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + sessionID: 'root-b' + }) + }) + + it('keeps the oldest blocker waiting when 128 newer blockers resolve', async () => { + const hooks = await createHooks([]) + + // Why: live blocker ownership cannot be LRU-evicted; 129 crosses the prior 128-entry cap. + for (let index = 1; index <= 129; index += 1) { + await hooks.event({ + event: attention('question.asked', `question-${String(index)}`, `session-${String(index)}`) + }) + } + for (let index = 2; index <= 129; index += 1) { + await hooks.event({ + event: resolution( + 'question.replied', + `question-${String(index)}`, + `session-${String(index)}` + ) + }) + } + + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'question-1', + sessionID: 'session-1' + }) + }) + + it('cleans only the disposed factory blocker', async () => { + const first = await createHooks([{ id: 'root-a' }, { id: 'child-a', parentID: 'root-a' }]) + const second = await createHooks([{ id: 'root-b' }, { id: 'child-b', parentID: 'root-b' }]) + + await first.event({ event: status('busy', 'root-a') }) + await second.event({ event: status('busy', 'root-b') }) + await first.event({ event: attention('question.asked', 'first-id', 'child-a') }) + await second.event({ event: attention('question.asked', 'second-id', 'child-b') }) + await second.dispose?.() + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'AskUserQuestion', + id: 'first-id', + sessionID: 'root-a' + }) + + await first.event({ + event: resolution('question.replied', 'first-id', 'child-a') + }) + expect(posts.at(-1)).toMatchObject({ + hook_event_name: 'SessionBusy', + sessionID: 'root-a' + }) + }) + + it('keeps child lifecycle, deltas, and text previews from overriding root status', async () => { + const hooks = await createHooks([{ id: 'root' }, { id: 'child', parentID: 'root' }]) + + await hooks.event({ event: status('busy', 'root') }) + await hooks.event({ event: status('busy', 'child') }) + await hooks.event({ event: status('idle', 'child') }) + await hooks.event({ + event: { + type: 'message.part.delta', + properties: { sessionID: 'child', field: 'text', delta: 'child work' } + } + }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'child', + info: { id: 'child-message', role: 'assistant' } + } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'child', + part: { + type: 'text', + text: 'private child preview', + messageID: 'child-message' + } + } + } + }) + + expect(names()).toEqual(['SessionBusy']) + await hooks.event({ event: status('idle', 'root') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('does not let a delayed child Idle invalidate an in-flight parent preview', async () => { + let releaseRootLookup: (() => void) | undefined + const rootLookup = new Promise((resolve) => { + releaseRootLookup = resolve + }) + let notifyRootLookup: (() => void) | undefined + const rootLookupStarted = new Promise((resolve) => { + notifyRootLookup = resolve + }) + let releaseChildLookup: (() => void) | undefined + const childLookup = new Promise((resolve) => { + releaseChildLookup = resolve + }) + let notifyChildLookup: (() => void) | undefined + const childLookupStarted = new Promise((resolve) => { + notifyChildLookup = resolve + }) + let calls = 0 + const sessions = [{ id: 'root' }, { id: 'child', parentID: 'root' }] + const list = vi.fn(async () => { + calls += 1 + if (calls === 1) { + notifyRootLookup?.() + await rootLookup + } else if (calls === 2) { + notifyChildLookup?.() + await childLookup + } + return { data: sessions } + }) + const hooks = await createHooks([], list) + + const seedRole = hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root', + info: { id: 'parent-message', role: 'assistant' } + } + } + }) + await rootLookupStarted + const preview = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'parent preview', messageID: 'parent-message' } + } + } + }) + const childIdle = hooks.event({ event: status('idle', 'child') }) + + try { + releaseRootLookup?.() + await childLookupStarted + await vi.waitFor(() => expect(names()).toEqual(['MessagePart'])) + } finally { + releaseChildLookup?.() + } + await Promise.all([seedRole, preview, childIdle]) + + // Why: child completion may clean its blockers, but it cannot cancel or + // replace a parent preview that was already on its way to the pane. + expect(names()).toEqual(['MessagePart']) + }) +}) diff --git a/src/main/opencode/hook-plugin-fail-open-ownership.test.ts b/src/main/opencode/hook-plugin-fail-open-ownership.test.ts new file mode 100644 index 00000000000..9f48059604f --- /dev/null +++ b/src/main/opencode/hook-plugin-fail-open-ownership.test.ts @@ -0,0 +1,439 @@ +/** + * Executes the generated OpenCode plugin source because fail-open ownership + * lives inside OpenCode's process, not in Orca's TypeScript runtime. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type SessionFixture = { id: string; parentID?: string } +type PluginEvent = { type: string; properties?: Record } +type PluginEventHandler = (input: { event: PluginEvent }) => Promise +type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } +type RecordedPost = { + hook_event_name: string + sessionID?: string +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +describe('OpenCode plugin fail-open ownership', () => { + let tempDir: string + let posts: RecordedPost[] + let savedEnv: Record + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-fail-open-plugin-')) + posts = [] + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + savedFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + function readPayload(init?: RequestInit): RecordedPost { + return JSON.parse(String(init?.body)).payload as RecordedPost + } + + async function loadHooks( + list: () => Promise<{ data: SessionFixture[] }> = async () => ({ + data: [{ id: 'root' }] + }) + ): Promise { + return loadHooksWithSession({ list }) + } + + async function loadHooksWithSession(session: object): Promise { + return loadHooksWithContext({ client: { session } }) + } + + async function loadHooksWithContext(context: unknown): Promise { + const pluginPath = join(tempDir, 'orca-opencode-status.mjs') + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + const module = (await import(pathToFileURL(pluginPath).href)) as { + OrcaOpenCodeStatusPlugin: (ctx: unknown) => Promise + } + return module.OrcaOpenCodeStatusPlugin(context) + } + + function status(type: 'busy' | 'idle' | 'retry', sessionID = 'root'): PluginEvent { + return { + type: 'session.status', + properties: { sessionID, status: { type } } + } + } + + function names(): string[] { + return posts.map((post) => post.hook_event_name) + } + + function rejectWhenAborted(signal: AbortSignal | undefined): Promise { + if (!signal) { + return Promise.reject(new Error('missing abort signal')) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + } + + it('retires a provisional Busy when the factory has no SDK client', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ event: status('busy') }) + await hooks.event({ event: status('idle') }) + + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('keeps concurrent provisional Busy owners active until each exact session retires', async () => { + const first = await loadHooksWithContext(undefined) + const second = await loadHooksWithContext(undefined) + + await first.event({ event: status('busy', 'unknown-a') }) + await second.event({ event: status('busy', 'unknown-b') }) + await first.event({ event: status('idle', 'unknown-a') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await second.event({ event: status('idle', 'unknown-b') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionIdle']) + }) + + it('keeps same-session provisional Busy ownership separate across factories', async () => { + const first = await loadHooksWithContext(undefined) + const second = await loadHooksWithContext(undefined) + + await first.event({ event: status('busy', 'same-session') }) + await second.event({ event: status('busy', 'same-session') }) + await first.event({ event: status('idle', 'same-session') }) + expect(names()).toEqual(['SessionBusy']) + + await second.event({ event: status('idle', 'same-session') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('does not evict an active provisional owner under high concurrency', async () => { + const hooks = await loadHooksWithContext(undefined) + + for (let index = 0; index < 129; index += 1) { + await hooks.event({ event: status('busy', `unknown-${index}`) }) + } + for (let index = 1; index < 129; index += 1) { + await hooks.event({ event: status('idle', `unknown-${index}`) }) + } + expect(names().at(-1)).toBe('SessionBusy') + expect(posts.at(-1)?.sessionID).toBe('unknown-0') + + await hooks.event({ event: status('idle', 'unknown-0') }) + expect(names().at(-1)).toBe('SessionIdle') + }) + + it('drains an older preview before unknown Idle retires its exact known root', async () => { + const sessions = Array.from({ length: 129 }, (_, index) => ({ id: `root-${index}` })) + let lookupFails = false + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: sessions } + }) + let releasePart: (() => void) | undefined + const delayedPart = new Promise((resolve) => { + releasePart = resolve + }) + let notifyPartStarted: (() => void) | undefined + const partStarted = new Promise((resolve) => { + notifyPartStarted = resolve + }) + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + if (payload.hook_event_name === 'MessagePart') { + notifyPartStarted?.() + await delayedPart + } + posts.push(payload) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const hooks = await loadHooks(list) + + await hooks.event({ event: status('busy', 'root-0') }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root-0', + info: { id: 'assistant-message', role: 'assistant' } + } + } + }) + const preview = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root-0', + part: { + type: 'text', + text: 'older preview', + messageID: 'assistant-message' + } + } + } + }) + await partStarted + + // Why: evict only root-0's ancestry cache entry while its preview is in flight. + for (let index = 1; index < sessions.length; index += 1) { + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: `root-${index}`, + info: { id: `message-${index}`, role: 'assistant' } + } + } + }) + } + lookupFails = true + const callsBeforeIdle = list.mock.calls.length + const idle = hooks.event({ event: status('idle', 'root-0') }) + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(callsBeforeIdle + 1)) + await new Promise((resolve) => { + setImmediate(resolve) + }) + const namesBeforeRelease = names() + + releasePart?.() + await Promise.all([preview, idle]) + + expect(namesBeforeRelease).toEqual(['SessionBusy']) + expect(names()).toEqual(['SessionBusy', 'MessagePart', 'SessionIdle']) + }) + + it('drops a delayed text handler after its Waiting blocker resolves to Idle', async () => { + let lookupBlocks = false + let releaseLookup: (() => void) | undefined + const delayedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + let notifyLookupStarted: (() => void) | undefined + const lookupStarted = new Promise((resolve) => { + notifyLookupStarted = resolve + }) + const list = vi.fn(async () => { + if (!lookupBlocks) { + throw new Error('lookup unavailable') + } + notifyLookupStarted?.() + await delayedLookup + return { data: [{ id: 'root' }] } + }) + const hooks = await loadHooks(list) + await hooks.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root', + info: { id: 'assistant-message', role: 'assistant' } + } + } + }) + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-root', sessionID: 'root' } + } + }) + lookupBlocks = true + const preview = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { + type: 'text', + text: 'stale preview', + messageID: 'assistant-message' + } + } + } + }) + await lookupStarted + + await hooks.event({ + event: { + type: 'question.replied', + properties: { requestID: 'question-root', sessionID: 'root' } + } + }) + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + + releaseLookup?.() + await preview + + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + }) + + it('disposes only its provisional Busy owner and reasserts the survivor', async () => { + const first = await loadHooksWithContext(undefined) + const second = await loadHooksWithContext(undefined) + + await first.event({ event: status('busy', 'unknown-a') }) + await second.event({ event: status('busy', 'unknown-b') }) + await second.dispose?.() + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionBusy']) + expect(posts.at(-1)?.sessionID).toBe('unknown-a') + + await first.dispose?.() + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionBusy', 'SessionIdle']) + }) + + it('lets exact unknown-session Idle clear a blocker without SDK lookup', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-unknown', sessionID: 'unknown' } + } + }) + await hooks.event({ event: status('idle', 'unknown') }) + + expect(names()).toEqual(['AskUserQuestion', 'SessionIdle']) + }) + + it('does not let another unknown session Busy overwrite an active blocker', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ + event: { + type: 'permission.asked', + properties: { id: 'permission-root', sessionID: 'root' } + } + }) + await hooks.event({ event: status('busy', 'other-session') }) + + expect(names()).toEqual(['PermissionRequest']) + }) + + it('ignores malformed attention that has no required sessionID', async () => { + const hooks = await loadHooksWithContext(undefined) + + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-without-session' } + } + }) + await hooks.event({ + event: { + type: 'permission.asked', + properties: { id: 'permission-without-session' } + } + }) + + expect(names()).toEqual([]) + }) + + it('retires provisional Busy after both ancestry lookups time out', async () => { + vi.useFakeTimers() + const list = vi.fn((options?: { signal?: AbortSignal }) => rejectWhenAborted(options?.signal)) + const hooks = await loadHooksWithSession({ list }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + expect(names()).toEqual(['SessionBusy']) + + const idle = hooks.event({ event: status('idle') }) + await vi.advanceTimersByTimeAsync(2_000) + await idle + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('promotes provisional Busy when later lookup confirms the root', async () => { + let lookupFails = true + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: [{ id: 'root' }] } + }) + const hooks = await loadHooksWithSession({ list }) + + await hooks.event({ event: status('busy') }) + lookupFails = false + await hooks.event({ + event: { + type: 'question.asked', + properties: { id: 'question-root', sessionID: 'root' } + } + }) + await hooks.event({ + event: { + type: 'question.replied', + properties: { requestID: 'question-root', sessionID: 'root' } + } + }) + await hooks.event({ event: status('idle') }) + + expect(names()).toEqual(['SessionBusy', 'AskUserQuestion', 'SessionBusy', 'SessionIdle']) + }) + + it('keeps a confirmed child provisionally Busy until its matching Idle', async () => { + let lookupFails = true + const list = vi.fn(async () => { + if (lookupFails) { + throw new Error('lookup unavailable') + } + return { data: [{ id: 'root' }, { id: 'child', parentID: 'root' }] } + }) + const hooks = await loadHooksWithSession({ list }) + + await hooks.event({ event: status('busy', 'child') }) + lookupFails = false + await hooks.event({ event: status('busy', 'child') }) + expect(names()).toEqual(['SessionBusy']) + + await hooks.event({ event: status('idle', 'child') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) +}) diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts new file mode 100644 index 00000000000..b6684ea38af --- /dev/null +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -0,0 +1,765 @@ +/** + * Executes the generated OpenCode plugin source because this delivery state + * lives inside OpenCode's process, not in Orca's TypeScript runtime. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>() +})) + +vi.mock('electron', () => ({ + app: { getPath: getPathMock } +})) + +import { _internals } from './hook-service' + +type SessionFixture = { id: string; parentID?: string } +type PluginEvent = { type: string; properties?: Record } +type PluginEventHandler = (input: { event: PluginEvent }) => Promise +type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } +type RecordedPost = { + hook_event_name: string + sessionID?: string +} + +const ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENDPOINT' +] as const + +describe('OpenCode plugin lifecycle delivery', () => { + let tempDir: string + let posts: RecordedPost[] + let savedEnv: Record + let savedFetch: typeof globalThis.fetch + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-opencode-lifecycle-plugin-')) + posts = [] + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + } + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + process.env.ORCA_AGENT_HOOK_PORT = '45678' + process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + delete process.env.ORCA_AGENT_HOOK_ENDPOINT + savedFetch = globalThis.fetch + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = savedFetch + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + function readPayload(init?: RequestInit): RecordedPost { + return JSON.parse(String(init?.body)).payload as RecordedPost + } + + async function loadHooks( + list: () => Promise<{ data: SessionFixture[] }> = async () => ({ + data: [{ id: 'root' }] + }) + ): Promise { + return loadHooksWithSession({ list }) + } + + async function loadHooksWithSession(session: object): Promise { + const pluginPath = join(tempDir, 'orca-opencode-status.mjs') + writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) + const module = (await import(pathToFileURL(pluginPath).href)) as { + OrcaOpenCodeStatusPlugin: (ctx: unknown) => Promise + } + return module.OrcaOpenCodeStatusPlugin({ client: { session } }) + } + + async function loadHandler( + list?: () => Promise<{ data: SessionFixture[] }> + ): Promise { + return (await loadHooks(list)).event + } + + function status(type: 'busy' | 'idle' | 'retry', sessionID = 'root'): PluginEvent { + return { + type: 'session.status', + properties: { sessionID, status: { type } } + } + } + + function delta(text: string, field = 'text', sessionID = 'root'): PluginEvent { + return { + type: 'message.part.delta', + properties: { + sessionID, + messageID: 'message-assistant', + partID: 'part-assistant', + field, + delta: text + } + } + } + + function names(): string[] { + return posts.map((post) => post.hook_event_name) + } + + function rejectWhenAborted(signal: AbortSignal | undefined): Promise { + if (!signal) { + return Promise.reject(new Error('missing abort signal')) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + } + + it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => { + let releaseFirstLookup: (() => void) | undefined + const firstLookup = new Promise((resolve) => { + releaseFirstLookup = resolve + }) + let notifyFirstLookupStarted: (() => void) | undefined + const firstLookupStarted = new Promise((resolve) => { + notifyFirstLookupStarted = resolve + }) + let calls = 0 + const list = vi.fn(async () => { + calls += 1 + if (calls === 1) { + notifyFirstLookupStarted?.() + await firstLookup + } + return { data: [{ id: 'root' }] } + }) + const handler = await loadHandler(list) + + const busy = handler({ event: status('busy') }) + await firstLookupStarted + const idle = handler({ event: status('idle') }) + + try { + // Why: OpenCode does not await event hooks, so the plugin must prevent + // the later idle lookup from overtaking the blocked busy lookup. + expect(list).toHaveBeenCalledTimes(1) + expect(posts).toHaveLength(0) + } finally { + releaseFirstLookup?.() + } + + await Promise.all([busy, idle]) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('retries an undelivered Busy transition after a non-2xx response', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + expect(names()).toEqual(['SessionBusy']) + + await vi.advanceTimersByTimeAsync(499) + expect(names()).toEqual(['SessionBusy']) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('aborts a hung Busy post before delivering the queued Idle transition', async () => { + vi.useFakeTimers() + let attempts = 0 + let firstSignal: AbortSignal | undefined + let notifyFirstFetchStarted: (() => void) | undefined + const firstFetchStarted = new Promise((resolve) => { + notifyFirstFetchStarted = resolve + }) + globalThis.fetch = vi.fn((_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + if (attempts > 1) { + return Promise.resolve(new Response(null, { status: 204 })) + } + firstSignal = init?.signal ?? undefined + notifyFirstFetchStarted?.() + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + const busy = handler({ event: status('busy') }) + const idle = handler({ event: status('idle') }) + await firstFetchStarted + + expect(firstSignal).toBeDefined() + await vi.advanceTimersByTimeAsync(1_999) + expect(names()).toEqual(['SessionBusy']) + await vi.advanceTimersByTimeAsync(1) + await Promise.all([busy, idle]) + + expect(firstSignal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('lets Idle supersede a pending Busy retry', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + return new Response(null, { + status: payload.hook_event_name === 'SessionBusy' ? 503 : 204 + }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: status('idle') }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + + // Why: a stale retry must not resurrect Working after OpenCode is done. + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('uses a current nonempty text delta to retry a dirty Busy transition immediately', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + expect(names()).toEqual(['SessionBusy']) + + // Why: current OpenCode streams deltas after an empty text-start part; the + // live delta proves work continued and should recover a lost Busy post. + await handler({ event: delta('hello') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('keeps backoff across OpenCode canonical and deprecated Idle duplicates', async () => { + vi.useFakeTimers() + let idleAttempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + if (payload.hook_event_name === 'SessionIdle') { + idleAttempts += 1 + return new Response(null, { status: idleAttempts === 1 ? 503 : 204 }) + } + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: status('idle') }) + await handler({ + event: { type: 'session.idle', properties: { sessionID: 'root' } } + }) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + + await vi.advanceTimersByTimeAsync(499) + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionIdle', 'SessionIdle']) + }) + + it('uses repeated Busy evidence to retry the same dirty state immediately', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: status('retry') }) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('does not turn empty or non-text deltas into immediate retry traffic', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts === 1 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: delta('reasoning', 'reasoning') }) + await handler({ event: delta('') }) + expect(names()).toEqual(['SessionBusy']) + + await vi.advanceTimersByTimeAsync(499) + expect(names()).toEqual(['SessionBusy']) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + }) + + it('backs off exponentially and caps outage retries at thirty seconds', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 503 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + expect(names()).toHaveLength(1) + + const retryDelays = [500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000] + for (const [index, delay] of retryDelays.entries()) { + await vi.advanceTimersByTimeAsync(delay - 1) + expect(names()).toHaveLength(index + 1) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toHaveLength(index + 2) + } + }) + + it('fails Busy open after a hung lookup and still lets queued Idle proceed', async () => { + vi.useFakeTimers() + let releaseLookup: (() => void) | undefined + const blockedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + let calls = 0 + const list = vi.fn(async () => { + calls += 1 + if (calls === 1) { + await blockedLookup + } + return { data: [{ id: 'root' }] } + }) + const handler = await loadHandler(list) + const busy = handler({ event: status('busy') }) + const idle = handler({ event: status('idle') }) + + await vi.advanceTimersByTimeAsync(1_999) + expect(list).toHaveBeenCalledTimes(1) + expect(posts).toHaveLength(0) + await vi.advanceTimersByTimeAsync(1) + try { + expect(list).toHaveBeenCalledTimes(2) + // Busy is safe to fail open: even a child means its root is working. + // Idle still requires a later confirmed-root lookup. + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + } finally { + releaseLookup?.() + } + await Promise.all([busy, idle]) + }) + + it('aborts a hung current-SDK point lookup through its second options argument', async () => { + vi.useFakeTimers() + let capturedParameters: unknown + let capturedSignal: AbortSignal | undefined + function get(parameters: unknown, options: { signal?: AbortSignal }) { + capturedParameters = parameters + capturedSignal = options?.signal + return rejectWhenAborted(capturedSignal) + } + const hooks = await loadHooksWithSession({ + get, + list: async () => ({ data: [{ id: 'root' }] }) + }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedParameters).toEqual({ sessionID: 'root' }) + expect(capturedSignal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('aborts a hung legacy-SDK point lookup through its one-object argument', async () => { + vi.useFakeTimers() + let capturedOptions: { path?: { id?: string }; signal?: AbortSignal } | undefined + function get(options: { path?: { id?: string }; signal?: AbortSignal }) { + capturedOptions = options + return rejectWhenAborted(options.signal) + } + const hooks = await loadHooksWithSession({ + get, + list: async () => ({ data: [{ id: 'root' }] }) + }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedOptions?.path).toEqual({ id: 'root' }) + expect(capturedOptions?.signal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('aborts a hung current-SDK list fallback through its second options argument', async () => { + vi.useFakeTimers() + let capturedParameters: unknown + let capturedSignal: AbortSignal | undefined + function list(parameters: unknown, options: { signal?: AbortSignal }) { + capturedParameters = parameters + capturedSignal = options?.signal + return rejectWhenAborted(capturedSignal) + } + const hooks = await loadHooksWithSession({ list }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedParameters).toEqual({}) + expect(capturedSignal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('aborts a hung legacy-SDK list fallback through its one-object argument', async () => { + vi.useFakeTimers() + let capturedOptions: { signal?: AbortSignal } | undefined + function list(options: { signal?: AbortSignal }) { + capturedOptions = options + return rejectWhenAborted(options.signal) + } + const hooks = await loadHooksWithSession({ list }) + + const busy = hooks.event({ event: status('busy') }) + await vi.advanceTimersByTimeAsync(2_000) + await busy + + expect(capturedOptions?.signal?.aborted).toBe(true) + expect(names()).toEqual(['SessionBusy']) + }) + + it('does not let a failed Busy retry overwrite delivered permission attention', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + const firstBusy = payload.hook_event_name === 'SessionBusy' && names().length === 1 + return new Response(null, { status: firstBusy ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ + event: { type: 'permission.asked', properties: { id: 'permission-1', sessionID: 'root' } } + }) + expect(names()).toEqual(['SessionBusy', 'PermissionRequest']) + + await vi.advanceTimersByTimeAsync(60_000) + expect(names()).toEqual(['SessionBusy', 'PermissionRequest']) + }) + + it('coalesces sustained text-delta recovery before returning to backoff', async () => { + vi.useFakeTimers() + let attempts = 0 + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + attempts += 1 + return new Response(null, { status: attempts < 3 ? 503 : 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ event: delta('stream-0') }) + for (let index = 1; index < 50; index += 1) { + await handler({ event: delta(`stream-${String(index)}`) }) + } + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + + await vi.advanceTimersByTimeAsync(999) + expect(names()).toHaveLength(2) + await vi.advanceTimersByTimeAsync(1) + expect(names()).toEqual(['SessionBusy', 'SessionBusy', 'SessionBusy']) + }) + + it('cancels lifecycle retries when OpenCode disposes the plugin', async () => { + vi.useFakeTimers() + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + return new Response(null, { status: 503 }) + }) as typeof globalThis.fetch + const hooks = await loadHooks() + + await hooks.event({ event: status('busy') }) + expect(hooks.dispose).toBeTypeOf('function') + await hooks.dispose?.() + await vi.advanceTimersByTimeAsync(60_000) + + expect(names()).toEqual(['SessionBusy']) + }) + + it('publishes final Idle when the last busy factory is disposed', async () => { + const hooks = await loadHooks() + + await hooks.event({ event: status('busy') }) + await hooks.dispose?.() + + expect(names()).toEqual(['SessionBusy', 'SessionIdle']) + }) + + it('publishes final Idle when MessagePart alone made the disposed factory Working', async () => { + const hooks = await loadHooks() + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'prompt before busy', messageID: 'user-message' } + } + } + }) + + await hooks.dispose?.() + + expect(names()).toEqual(['MessagePart', 'SessionIdle']) + }) + + it('reasserts an already-delivered Idle after a later MessagePart', async () => { + const hooks = await loadHooks() + await hooks.event({ event: status('busy') }) + await hooks.event({ event: status('idle') }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'late prompt', messageID: 'user-message' } + } + } + }) + + await hooks.dispose?.() + + expect(names()).toEqual(['SessionBusy', 'SessionIdle', 'MessagePart', 'SessionIdle']) + }) + + it('lets OpenCode duplicate Idle supersede a later-delivered MessagePart', async () => { + const hooks = await loadHooks() + await hooks.event({ event: status('busy') }) + await hooks.event({ event: status('idle') }) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'late prompt', messageID: 'user-message' } + } + } + }) + + await hooks.event({ event: { type: 'session.idle', properties: { sessionID: 'root' } } }) + + expect(names()).toEqual(['SessionBusy', 'SessionIdle', 'MessagePart', 'SessionIdle']) + }) + + it('does not treat a failed MessagePart attempt as delivered Working authority', async () => { + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const payload = readPayload(init) + posts.push(payload) + return new Response(null, { + status: payload.hook_event_name === 'MessagePart' ? 503 : 204 + }) + }) as typeof globalThis.fetch + const hooks = await loadHooks() + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root', info: { id: 'user-message', role: 'user' } } + } + }) + await hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'undelivered prompt', messageID: 'user-message' } + } + } + }) + + await hooks.dispose?.() + + expect(names()).toEqual(['MessagePart']) + }) + + it('does not publish a MessagePart after its factory is disposed during lookup', async () => { + let releaseLookup: (() => void) | undefined + const blockedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + let calls = 0 + const list = vi.fn(async () => { + calls += 1 + if (calls === 2) { + await blockedLookup + } + return { data: [{ id: 'seed' }, { id: 'root' }] } + }) + const hooks = await loadHooks(list) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'seed', info: { id: 'user-message', role: 'user' } } + } + }) + + const latePart = hooks.event({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text: 'late prompt', messageID: 'user-message' } + } + } + }) + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2)) + await hooks.dispose?.() + releaseLookup?.() + await latePart + + expect(names()).toEqual([]) + }) + + it('drops a disposed factory pending preview while another factory stays active', async () => { + vi.useFakeTimers() + const first = await loadHooks() + const second = await loadHooks() + await first.event({ + event: { + type: 'message.updated', + properties: { + sessionID: 'root', + info: { id: 'assistant-message', role: 'assistant' } + } + } + }) + const part = (text: string): PluginEvent => ({ + type: 'message.part.updated', + properties: { + sessionID: 'root', + part: { type: 'text', text, messageID: 'assistant-message' } + } + }) + + await first.event({ event: part('first preview') }) + await first.event({ event: part('pending preview') }) + await first.dispose?.() + await vi.advanceTimersByTimeAsync(1_000) + await second.dispose?.() + + expect(names()).toEqual(['MessagePart', 'SessionIdle']) + }) + + it('keeps the pane Busy until every concurrent root session is idle', async () => { + const sessions = [{ id: 'root-a' }, { id: 'root-b' }] + const handler = await loadHandler(async () => ({ data: sessions })) + + await handler({ event: status('busy', 'root-a') }) + await handler({ event: status('busy', 'root-b') }) + await handler({ event: status('idle', 'root-a') }) + expect(names()).not.toContain('SessionIdle') + + await handler({ event: status('idle', 'root-b') }) + expect(names().filter((name) => name === 'SessionIdle')).toHaveLength(1) + expect(names().at(-1)).toBe('SessionIdle') + }) + + it('reasserts Busy through the refreshed endpoint after a live text delta', async () => { + const deliveries: { url: string; token: string | null }[] = [] + globalThis.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + posts.push(readPayload(init)) + deliveries.push({ + url: String(url), + token: new Headers(init?.headers).get('X-Orca-Agent-Hook-Token') + }) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + process.env.ORCA_AGENT_HOOK_PORT = '56789' + process.env.ORCA_AGENT_HOOK_TOKEN = 'refreshed-token' + await handler({ event: delta('still working') }) + + expect(names()).toEqual(['SessionBusy', 'SessionBusy']) + expect(deliveries).toEqual([ + { url: 'http://127.0.0.1:45678/hook/opencode', token: 'test-token' }, + { url: 'http://127.0.0.1:56789/hook/opencode', token: 'refreshed-token' } + ]) + }) + + it('clears unanswered question attention on authoritative root Idle', async () => { + const handler = await loadHandler() + + await handler({ event: status('busy') }) + await handler({ + event: { type: 'question.asked', properties: { id: 'question-1', sessionID: 'root' } } + }) + expect(names().at(-1)).toBe('AskUserQuestion') + + // OpenCode can finish the question tool without question.replied. + await handler({ event: status('idle') }) + expect(names().at(-1)).toBe('SessionIdle') + }) +}) diff --git a/src/main/opencode/hook-plugin-message-part-throttle.test.ts b/src/main/opencode/hook-plugin-message-part-throttle.test.ts index d53fe826665..2468e7457e1 100644 --- a/src/main/opencode/hook-plugin-message-part-throttle.test.ts +++ b/src/main/opencode/hook-plugin-message-part-throttle.test.ts @@ -167,6 +167,82 @@ describe('OpenCode plugin MessagePart throttling', () => { expect(posts[1].body.payload.text).toBe('first final') }) + it('waits for an in-flight preview before delivering SessionIdle', async () => { + let releasePart: (() => void) | undefined + const delayedPart = new Promise((resolve) => { + releasePart = resolve + }) + globalThis.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const post = { url: String(url), body: JSON.parse(String(init?.body)) } as RecordedPost + posts.push(post) + if (post.body.payload.hook_event_name === 'MessagePart') { + await delayedPart + } + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadPluginEventHandler() + await seedAssistantRole(handler) + await handler({ + event: { + type: 'session.status', + properties: { sessionID: 'session-1', status: { type: 'busy' } } + } + }) + posts.length = 0 + + await handler(assistantPartEvent('completed reply')) + const idle = handler({ + event: { type: 'session.idle', properties: { sessionID: 'session-1' } } + }) + await vi.advanceTimersByTimeAsync(0) + expect(posts.map((post) => post.body.payload.hook_event_name)).toEqual(['MessagePart']) + + releasePart?.() + await idle + expect(posts.map((post) => post.body.payload.hook_event_name)).toEqual([ + 'MessagePart', + 'SessionIdle' + ]) + }) + + it('clears question attention when its tool part completes without a reply event', async () => { + const handler = await loadPluginEventHandler() + await handler({ + event: { + type: 'session.status', + properties: { sessionID: 'session-1', status: { type: 'busy' } } + } + }) + await handler({ + event: { + type: 'question.asked', + properties: { + id: 'question-1', + sessionID: 'session-1', + tool: { messageID: 'message-1', callID: 'call-1' } + } + } + }) + expect(posts.at(-1)?.body.payload.hook_event_name).toBe('AskUserQuestion') + + await handler({ + event: { + type: 'message.part.updated', + properties: { + sessionID: 'session-1', + part: { + type: 'tool', + tool: 'question', + messageID: 'message-1', + callID: 'call-1', + state: { status: 'completed' } + } + } + } + }) + expect(posts.at(-1)?.body.payload.hook_event_name).toBe('SessionBusy') + }) + it('posts user prompts immediately without consuming the assistant throttle slot', async () => { const handler = await loadPluginEventHandler() await handler({ diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index f3956519ee7..7d88e98e2cd 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,10 +32,23 @@ describe('OpenCode hook plugin source', () => { const source = _internals.getOpenCodePluginSource() expect(source).toContain('async function isChildSession(client, sessionID)') - expect(source).toContain('const sessions = await client.session.list();') - expect(source).toContain('const isChild = !!session?.parentID;') - expect(source).toContain('if (sessionID && (await isChildSession(client, sessionID))) {') - expect(source).toContain('return true;') + expect(source).toContain('walkSessionParents(client, sessionID, controller.signal)') + expect(source).toContain('currentSessionID = session.parentID;') + expect(source).toContain('rememberSessionRoot(id, currentSessionID)') + expect(source).toContain('{ path: { id: sessionID }, signal }') + expect(source).toContain('[{ sessionID }, { signal }]') + expect(source.indexOf('[{ sessionID }, { signal }]')).toBeLessThan( + source.indexOf('{ path: { id: sessionID }, signal }') + ) + expect(source).toContain('return client.session.list({}, { signal });') + expect(source).toContain('return client.session.list({ signal });') + expect(source).toContain('return rootSessionID === null ? null : rootSessionID !== sessionID;') + expect(source).toContain( + 'if (sessionID && (await isChildSession(client, sessionID)) !== false) {' + ) + expect(source).toContain( + 'if (sessionID && (await isChildSession(client, sessionID)) !== false) {\n return;\n }' + ) }) it('still accepts an optional opaque plugin context instead of destructuring', () => { @@ -86,20 +99,27 @@ describe('OpenCode hook plugin source', () => { // Why: forward question.asked too (not just permission.asked), else the pane stays "working" while the agent idles on a human reply. const source = _internals.getOpenCodePluginSource() - expect(source).toContain('if (event.type === "question.asked")') - expect(source).toContain('await post("AskUserQuestion", event.properties || {});') + expect(source).toContain('event.type === "question.asked"') + expect(source).toContain( + 'event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion"' + ) + expect(source).toContain('await setAttention(') }) it('forwards sessionID on status and message posts for resume metadata', () => { const source = _internals.getOpenCodePluginSource() expect(source).toContain( - 'await post("MessagePart", { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID });' + '{ role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },' ) expect(source).toContain('messageID: pending.messageID,') expect(source).toContain('sessionID: pending.sessionID,') - expect(source).toContain('await setStatus("busy", { sessionID });') - expect(source.match(/await setStatus\("idle", \{ sessionID \}\);/g) ?? []).toHaveLength(2) + expect(source).toContain( + 'await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);' + ) + expect(source).toContain( + 'await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);' + ) }) it('guards endpoint-file parse warnings with a process-lifetime latch', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index c2baa488dc9..32781775a87 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -128,12 +128,48 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' };', '}', '', + 'function hookEndpointKey() {', + ' const coords = resolveHookCoords();', + ' return [coords.port || "", coords.token || "", coords.env, coords.version].join("\\u0000");', + '}', + '', 'function getStatusType(event) {', ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', '}', '', - 'let lastStatus = "idle";', - 'const childSessionById = new Map();', + 'const HOOK_POST_TIMEOUT_MS = 2000;', + 'const SESSION_LOOKUP_TIMEOUT_MS = 2000;', + 'const MAX_SESSION_ANCESTRY_DEPTH = 32;', + 'const STATUS_RETRY_BASE_MS = 500;', + 'const STATUS_RETRY_MAX_MS = 30000;', + 'let desiredStatus = "idle";', + 'let desiredHookEventName = "SessionIdle";', + 'let desiredStatusKey = "idle:";', + 'let desiredStatusProperties = {};', + 'let desiredFactoryID = null;', + 'let deliveredStatusKey = "idle:";', + 'let deliveredEndpointKey = "";', + 'let statusDeliveryDirty = false;', + 'let statusRevision = 0;', + 'let statusRetryAttempt = 0;', + 'let statusRetryTimer = null;', + 'let lifecycleQueue = Promise.resolve();', + 'let busyRecoveryQueued = false;', + 'let busyRecoveryUsed = false;', + 'let busyRecoveryEndpointKey = "";', + 'let stateArrivalRevision = 0;', + '// Why: OpenCode can create directory-scoped factories and concurrent root', + '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', + 'let nextFactoryID = 0;', + 'const activeFactoryIDs = new Set();', + 'const disposingFactoryIDs = new Set();', + 'const busyRootOwnerBySessionID = new Map();', + '// Why: a matching Idle must retire fail-open Busy even when the SDK client', + '// is unavailable, without granting an unrelated unknown Idle authority.', + 'const provisionalBusyByKey = new Map();', + 'const pendingAttentionByKey = new Map();', + 'const rootSessionById = new Map();', + 'const rootSessionLookupById = new Map();', '', '// Why: message.part.updated re-sends the FULL accumulated text of the part', '// after every streamed append, so posting each event forwards O(n^2) bytes', @@ -146,27 +182,49 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { 'const MESSAGE_PART_MAX_CHARS = 4000;', 'let pendingAssistantPart = null;', 'let assistantPartFlushTimer = null;', + 'let messagePartPostInFlight = null;', + 'let deliveredMessagePartFactoryID = null;', 'let lastAssistantPartPostAt = 0;', '', 'function capMessagePartText(text) {', ' return text.length > MESSAGE_PART_MAX_CHARS ? text.slice(0, MESSAGE_PART_MAX_CHARS) : text;', '}', '', - 'async function flushPendingAssistantPart() {', + 'async function postMessagePart(properties, factoryID) {', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' const delivery = post("MessagePart", properties);', + ' messagePartPostInFlight = delivery;', + ' try {', + ' const delivered = await delivery;', + ' if (delivered) deliveredMessagePartFactoryID = factoryID;', + ' } finally {', + ' if (messagePartPostInFlight === delivery) messagePartPostInFlight = null;', + ' }', + '}', + '', + 'async function flushPendingAssistantPart(force = false) {', ' if (assistantPartFlushTimer) {', ' clearTimeout(assistantPartFlushTimer);', ' assistantPartFlushTimer = null;', ' }', + ' // Why: an idle/waiting transition must wait for every older preview;', + ' // keep one post in flight while later snapshots coalesce in memory.', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', ' const pending = pendingAssistantPart;', ' pendingAssistantPart = null;', ' if (!pending) return;', + ' if (', + ' !activeFactoryIDs.has(pending.factoryID) ||', + ' disposingFactoryIDs.has(pending.factoryID)', + ' ) return;', + ' if (!force && pending.authorityRevision !== stateArrivalRevision) return;', ' lastAssistantPartPostAt = Date.now();', - ' await post("MessagePart", {', + ' await postMessagePart({', ' role: pending.role,', ' text: capMessagePartText(pending.text),', ' messageID: pending.messageID,', ' sessionID: pending.sessionID,', - ' });', + ' }, pending.factoryID);', '}', '', 'function queueAssistantPart(part) {', @@ -204,29 +262,114 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { '// Why: oh-my-opencode style tools spawn child sessions that emit their', '// own session.idle / message events. Those child completions must not', '// flip the root Orca pane to done or overwrite the parent turn preview.', - '// Detect child sessions by checking `parentID` via client.session.list(),', - '// cache the result per session, and fail closed (assume child) on lookup errors', - '// so a transient SDK failure cannot create false "done" transitions.', - 'async function isChildSession(client, sessionID) {', - ' if (!sessionID) return true;', - ' if (childSessionById.has(sessionID)) return childSessionById.get(sessionID);', - ' if (!client?.session?.list) return true;', + '// Resolve the full parentID chain so descendant attention can be attributed', + '// to the root while child completion and previews remain non-authoritative.', + 'async function resolveRootSessionID(client, sessionID) {', + ' if (!sessionID) return null;', + ' if (rootSessionById.has(sessionID)) return rootSessionById.get(sessionID);', + ' if (!client?.session?.get && !client?.session?.list) return null;', + ' if (rootSessionLookupById.has(sessionID)) return rootSessionLookupById.get(sessionID);', + ' const lookup = lookupRootSessionID(client, sessionID);', + ' rootSessionLookupById.set(sessionID, lookup);', ' try {', - ' const sessions = await client.session.list();', - ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', - ' const session = list.find((entry) => entry?.id === sessionID);', - ' const isChild = !!session?.parentID;', - ' if (childSessionById.size >= 128) {', - ' const first = childSessionById.keys().next().value;', - ' if (first !== undefined) childSessionById.delete(first);', + ' return await lookup;', + ' } finally {', + ' if (rootSessionLookupById.get(sessionID) === lookup) {', + ' rootSessionLookupById.delete(sessionID);', ' }', - ' childSessionById.set(sessionID, isChild);', - ' return isChild;', - ' } catch {', - ' return true;', ' }', '}', '', + 'async function isChildSession(client, sessionID) {', + ' const rootSessionID = await resolveRootSessionID(client, sessionID);', + ' return rootSessionID === null ? null : rootSessionID !== sessionID;', + '}', + '', + 'function rememberSessionRoot(sessionID, rootSessionID) {', + ' if (rootSessionById.size >= 128 && !rootSessionById.has(sessionID)) {', + ' const first = rootSessionById.keys().next().value;', + ' if (first !== undefined) rootSessionById.delete(first);', + ' }', + ' rootSessionById.set(sessionID, rootSessionID);', + '}', + '', + 'async function lookupRootSessionID(client, sessionID) {', + ' const controller = new AbortController();', + ' let timeout;', + ' const deadline = new Promise((_, reject) => {', + ' timeout = setTimeout(() => {', + ' controller.abort();', + ' reject(new Error("session lookup timed out"));', + ' }, SESSION_LOOKUP_TIMEOUT_MS);', + ' if (timeout.unref) timeout.unref();', + ' });', + ' try {', + ' const rootSessionID = await Promise.race([', + ' walkSessionParents(client, sessionID, controller.signal),', + ' deadline,', + ' ]);', + ' return rootSessionID;', + ' } catch {', + ' return null;', + ' } finally {', + ' clearTimeout(timeout);', + ' }', + '}', + '', + 'async function walkSessionParents(client, sessionID, signal) {', + ' const lineage = [];', + ' let currentSessionID = sessionID;', + ' // Why: malformed or unexpectedly deep ancestry must not monopolize the', + ' // lifecycle FIFO even when every individual SDK lookup succeeds.', + ' while (lineage.length < MAX_SESSION_ANCESTRY_DEPTH) {', + ' const cachedRoot = rootSessionById.get(currentSessionID);', + ' if (cachedRoot) {', + ' for (const id of lineage) rememberSessionRoot(id, cachedRoot);', + ' return cachedRoot;', + ' }', + ' if (lineage.includes(currentSessionID)) return null;', + ' lineage.push(currentSessionID);', + ' const sessions = await lookupSessionList(client, currentSessionID, signal);', + ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', + ' const session = list.find((entry) => entry?.id === currentSessionID);', + ' if (!session) return null;', + ' if (!session.parentID) {', + ' for (const id of lineage) rememberSessionRoot(id, currentSessionID);', + ' return currentSessionID;', + ' }', + ' currentSessionID = session.parentID;', + ' }', + ' return null;', + '}', + '', + 'async function lookupSessionList(client, sessionID, signal) {', + ' // Why: point lookup avoids the SDK list page dropping older children;', + ' // current SDKs put AbortSignal in a second options argument, while legacy', + ' // generated clients accept one request-options object.', + ' if (client?.session?.get) {', + ' const calls = client.session.get.length >= 2', + ' ? [', + ' [{ sessionID }, { signal }],', + ' [{ path: { id: sessionID }, signal }],', + ' ]', + ' : [[{ path: { id: sessionID }, signal }]];', + ' for (const args of calls) {', + ' try {', + ' const result = await client.session.get(...args);', + ' if (result?.data?.id === sessionID) return { data: [result.data] };', + ' } catch {', + ' if (signal.aborted) throw new Error("session lookup aborted");', + ' // Try the other supported SDK generation, then list fallback.', + ' }', + ' }', + ' }', + ' if (!client?.session?.list) return { data: [] };', + ' if (client.session.list.length >= 2) {', + ' return client.session.list({}, { signal });', + ' }', + ' return client.session.list({ signal });', + '}', + '', 'async function post(hookEventName, extraProperties) {', ' // Why: resolve coords per post — the endpoint file may have been', ' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs', @@ -234,7 +377,7 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' // the OpenCode process), not per-Orca-instance.', ' const coords = resolveHookCoords();', ' const paneKey = process.env.ORCA_PANE_KEY;', - ' if (!coords.port || !coords.token || !paneKey) return;', + ' if (!coords.port || !coords.token || !paneKey) return false;', ` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`, ' const body = JSON.stringify({', ' paneKey,', @@ -245,28 +388,421 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' version: coords.version,', ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', ' });', + ' const controller = new AbortController();', + ' const timeout = setTimeout(() => controller.abort(), HOOK_POST_TIMEOUT_MS);', + ' if (timeout.unref) timeout.unref();', ' try {', - ' await fetch(url, {', + ' const response = await fetch(url, {', ' method: "POST",', ' headers: {', ' "Content-Type": "application/json",', ' "X-Orca-Agent-Hook-Token": coords.token,', ' },', ' body,', + ' signal: controller.signal,', ' });', + ' return response.ok;', ' } catch {', ' // Why: OpenCode session events must never fail the agent run just', ' // because Orca is unavailable or the local loopback request failed.', + ' return false;', + ' } finally {', + ' clearTimeout(timeout);', ' }', '}', '', - 'async function setStatus(next, extraProperties) {', - ' // Why: dedupe so a flurry of session.status idle events after a turn', - ' // does not spam the dashboard with redundant done transitions.', - ' if (lastStatus === next) return;', - ' lastStatus = next;', + 'function enqueueLifecycle(task) {', + ' // Why: OpenCode intentionally fire-and-forgets hook promises, so async', + ' // lookups and posts need their own FIFO to preserve event order.', + ' const run = lifecycleQueue.then(async () => {', + ' try {', + ' await task();', + ' } catch {', + ' // Hook delivery must never reject into OpenCode.', + ' }', + ' });', + ' lifecycleQueue = run;', + ' return run;', + '}', + '', + 'function clearStatusRetry() {', + ' if (statusRetryTimer) clearTimeout(statusRetryTimer);', + ' statusRetryTimer = null;', + '}', + '', + 'function scheduleStatusRetry(revision) {', + ' if (', + ' statusRetryTimer ||', + ' revision !== statusRevision ||', + ' !statusDeliveryDirty ||', + ' !activeFactoryIDs.has(desiredFactoryID)', + ' ) return;', + ' const delay = Math.min(', + ' STATUS_RETRY_BASE_MS * Math.pow(2, Math.min(statusRetryAttempt, 6)),', + ' STATUS_RETRY_MAX_MS', + ' );', + ' statusRetryAttempt = Math.min(statusRetryAttempt + 1, 7);', + ' statusRetryTimer = setTimeout(() => {', + ' statusRetryTimer = null;', + ' void enqueueLifecycle(async () => {', + ' if (', + ' revision !== statusRevision ||', + ' !statusDeliveryDirty ||', + ' !activeFactoryIDs.has(desiredFactoryID)', + ' ) return;', + ' await publishDesiredStatus(revision);', + ' });', + ' }, delay);', + ' if (statusRetryTimer.unref) statusRetryTimer.unref();', + '}', + '', + 'async function publishDesiredStatus(revision) {', + ' if (revision !== statusRevision) return;', + ' if (!activeFactoryIDs.has(desiredFactoryID)) return;', + ' const endpointKey = hookEndpointKey();', + ' if (', + ' !statusDeliveryDirty &&', + ' deliveredStatusKey === desiredStatusKey &&', + ' deliveredEndpointKey === endpointKey &&', + ' deliveredMessagePartFactoryID === null', + ' ) return;', + ' const delivered = await post(desiredHookEventName, desiredStatusProperties);', + ' if (revision !== statusRevision) return;', + ' if (!delivered) {', + ' statusDeliveryDirty = true;', + ' scheduleStatusRetry(revision);', + ' return;', + ' }', + ' clearStatusRetry();', + ' statusRetryAttempt = 0;', + ' deliveredStatusKey = desiredStatusKey;', + ' deliveredEndpointKey = endpointKey;', + ' deliveredMessagePartFactoryID = null;', + ' statusDeliveryDirty = false;', + '}', + '', + 'async function setDeliveryTarget(', + ' next,', + ' nextKey,', + ' hookEventName,', + ' extraProperties,', + ' factoryID', + ') {', + ' const endpointChanged = deliveredEndpointKey !== hookEndpointKey();', + ' if (', + ' nextKey === desiredStatusKey &&', + ' nextKey === deliveredStatusKey &&', + ' desiredFactoryID === factoryID &&', + ' !statusDeliveryDirty &&', + ' !endpointChanged &&', + ' deliveredMessagePartFactoryID === null', + ' ) return;', + ' const targetChanged = nextKey !== desiredStatusKey || desiredFactoryID !== factoryID;', + ' clearStatusRetry();', + ' if (targetChanged) {', + ' statusRetryAttempt = 0;', + ' busyRecoveryUsed = false;', + ' busyRecoveryEndpointKey = "";', + ' }', + ' desiredStatus = next;', + ' desiredHookEventName = hookEventName;', + ' desiredStatusKey = nextKey;', + ' desiredStatusProperties = extraProperties || {};', + ' desiredFactoryID = factoryID;', + ' statusDeliveryDirty =', + ' statusDeliveryDirty ||', + ' deliveredMessagePartFactoryID !== null ||', + ' deliveredStatusKey !== nextKey ||', + ' endpointChanged;', + ' const revision = ++statusRevision;', + ' await publishDesiredStatus(revision);', + '}', + '', + 'async function setStatus(next, extraProperties, factoryID) {', + ' const nextKey = next + ":" + (extraProperties?.sessionID || "");', ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', - ' await post(hookEventName, extraProperties);', + ' await setDeliveryTarget(next, nextKey, hookEventName, extraProperties, factoryID);', + '}', + '', + 'async function setAttention(hookEventName, properties, factoryID, sourceSessionID) {', + ' const requestID = properties?.id || properties?.sessionID || "";', + ' const requestKey = attentionKey(factoryID, hookEventName, requestID, sourceSessionID);', + ' await flushPendingAssistantPart(true);', + ' await setDeliveryTarget(', + ' "waiting",', + ' "waiting:" + requestKey,', + ' hookEventName,', + ' properties,', + ' factoryID', + ' );', + '}', + '', + 'function recoverBusyFromDelta(client, sessionID, factoryID) {', + ' if (busyRecoveryQueued) return lifecycleQueue;', + ' if (', + ' busyRecoveryUsed &&', + ' busyRecoveryEndpointKey === hookEndpointKey()', + ' ) return lifecycleQueue;', + ' busyRecoveryQueued = true;', + ' return enqueueLifecycle(async () => {', + ' try {', + ' if (!activeFactoryIDs.has(factoryID)) return;', + ' if (sessionID && (await isChildSession(client, sessionID)) === true) return;', + ' if (!activeFactoryIDs.has(factoryID)) return;', + ' const endpointKey = hookEndpointKey();', + ' const endpointChanged = deliveredEndpointKey !== endpointKey;', + ' if (desiredStatus !== "busy" || (!statusDeliveryDirty && !endpointChanged)) return;', + ' if (busyRecoveryUsed && !endpointChanged) return;', + ' busyRecoveryUsed = true;', + ' busyRecoveryEndpointKey = endpointKey;', + ' clearStatusRetry();', + ' statusDeliveryDirty = true;', + ' const revision = ++statusRevision;', + ' await publishDesiredStatus(revision);', + ' } finally {', + ' busyRecoveryQueued = false;', + ' }', + ' });', + '}', + '', + 'function currentAttention() {', + ' let latestQuestion = null;', + ' for (const attention of pendingAttentionByKey.values()) {', + ' if (attention.hookEventName === "PermissionRequest") return attention;', + ' latestQuestion = attention;', + ' }', + ' return latestQuestion;', + '}', + '', + 'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {', + ' // Why: custom plugins may reuse request IDs across sessions or factories;', + ' // JSON tuple identity prevents one owner from hiding another blocker.', + ' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);', + '}', + '', + 'function clearAttentionForSession(sessionID, factoryID) {', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' if (attention.sourceSessionID === sessionID && attention.factoryID === factoryID) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', + 'function clearQuestionForToolPart(part, sessionID, factoryID) {', + ' if (', + ' part?.type !== "tool" ||', + ' part.tool !== "question" ||', + ' (part.state?.status !== "completed" && part.state?.status !== "error")', + ' ) return null;', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' const tool = attention.properties?.tool;', + ' if (', + ' attention.hookEventName === "AskUserQuestion" &&', + ' attention.sourceSessionID === sessionID &&', + ' attention.factoryID === factoryID &&', + ' tool?.messageID === part.messageID &&', + ' tool?.callID === part.callID', + ' ) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', + 'function clearAttentionForResolution(event, sessionID, factoryID) {', + ' const hookEventName =', + ' event.type === "permission.replied" ? "PermissionRequest" : "AskUserQuestion";', + ' const requestID = event.properties?.requestID || "";', + ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', + ' const attention = pendingAttentionByKey.get(key);', + ' if (!attention || attention.sourceSessionID !== sessionID) return null;', + ' pendingAttentionByKey.delete(key);', + ' return attention.properties?.sessionID || sessionID;', + '}', + '', + 'function provisionalBusyKey(factoryID, sessionID) {', + ' return JSON.stringify([factoryID, sessionID || ""]);', + '}', + '', + 'function rememberProvisionalBusy(sessionID, factoryID) {', + ' const key = provisionalBusyKey(factoryID, sessionID);', + ' // Why: active ownership cannot be LRU-evicted without allowing false', + ' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.', + ' provisionalBusyByKey.delete(key);', + ' provisionalBusyByKey.set(key, { sessionID, factoryID });', + '}', + '', + 'function clearProvisionalBusy(sessionID, factoryID) {', + ' return provisionalBusyByKey.delete(provisionalBusyKey(factoryID, sessionID));', + '}', + '', + 'function clearKnownBusyRoot(sessionID, factoryID) {', + ' if (busyRootOwnerBySessionID.get(sessionID) !== factoryID) return false;', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' return true;', + '}', + '', + 'function latestBusyOwner() {', + ' let latest = null;', + ' for (const [sessionID, factoryID] of busyRootOwnerBySessionID) {', + ' latest = { sessionID, factoryID };', + ' }', + ' for (const provisional of provisionalBusyByKey.values()) {', + ' latest = provisional;', + ' }', + ' return latest;', + '}', + '', + 'async function publishAggregateStatus(fallbackFactoryID, preferredSessionID) {', + ' const attention = currentAttention();', + ' if (attention) {', + ' await setAttention(', + ' attention.hookEventName,', + ' attention.properties,', + ' attention.factoryID,', + ' attention.sourceSessionID', + ' );', + ' return;', + ' }', + ' const busyOwner = latestBusyOwner();', + ' if (busyOwner) {', + ' await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);', + ' return;', + ' }', + ' await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);', + '}', + '', + 'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {', + ' stateArrivalRevision += 1;', + ' // Why: exact blocker/Busy retirement is authoritative even if ancestry', + ' // lookup failed; every older preview must settle before its replacement.', + ' await flushPendingAssistantPart(true);', + ' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);', + '}', + '', + 'async function handleLifecycleEvent(client, event, factoryID) {', + ' const sessionID = event.properties?.sessionID;', + ' const statusType = getStatusType(event);', + ' const isResolutionEvent =', + ' event.type === "permission.replied" ||', + ' event.type === "question.replied" ||', + ' event.type === "question.rejected";', + ' if (isResolutionEvent) {', + ' // Why: the stored owner already identifies the root, so replies clear', + ' // immediately even when OpenCode session lookup is slow or unavailable.', + ' const rootSessionID = clearAttentionForResolution(event, sessionID, factoryID);', + ' if (rootSessionID) await publishOwnershipChange(factoryID, rootSessionID);', + ' return;', + ' }', + ' const isAttentionEvent =', + ' event.type === "permission.asked" ||', + ' event.type === "question.asked";', + " // Why: attention without OpenCode's required sessionID cannot be", + ' // correlated to a later reply/Idle, so it must not become UI authority.', + ' if (isAttentionEvent && !sessionID) return;', + ' const canFailOpen =', + ' statusType === "busy" || statusType === "retry" || isAttentionEvent;', + ' const rootSessionID = sessionID ? await resolveRootSessionID(client, sessionID) : null;', + ' const childState = rootSessionID === null ? null : rootSessionID !== sessionID;', + ' const isIdleEvent = event.type === "session.idle" || statusType === "idle";', + ' const resolvedProvisionalBusy =', + ' childState === false || (childState === true && isIdleEvent)', + ' ? clearProvisionalBusy(sessionID, factoryID)', + ' : false;', + ' if (resolvedProvisionalBusy && childState === false) {', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' }', + ' // Why: child work rolls up to the pane; ignore its normal lifecycle noise,', + ' // but preserve blockers that still require the pane owner to respond.', + ' if (childState === true && !isAttentionEvent) {', + ' let attentionRootSessionID = null;', + ' if (isIdleEvent) {', + ' attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', + ' }', + ' if (resolvedProvisionalBusy || attentionRootSessionID) {', + ' await publishOwnershipChange(factoryID, attentionRootSessionID || rootSessionID);', + ' }', + ' return;', + ' }', + ' if (childState === null && !canFailOpen) {', + ' if (isIdleEvent) {', + ' // Why: recorded ownership can safely retire a blocker during an SDK', + ' // outage without granting unknown child Idle authority over root state.', + ' const attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', + ' const clearedProvisionalBusy = clearProvisionalBusy(sessionID, factoryID);', + ' const clearedKnownBusyRoot = clearKnownBusyRoot(sessionID, factoryID);', + ' if (attentionRootSessionID || clearedProvisionalBusy || clearedKnownBusyRoot) {', + ' await publishOwnershipChange(factoryID, attentionRootSessionID || sessionID);', + ' }', + ' }', + ' return;', + ' }', + ' if (childState === null && (statusType === "busy" || statusType === "retry")) {', + ' // Unknown lineage may be child work, so keep exact provisional ownership', + ' // only until matching Idle/disposal; other blockers still take priority.', + ' clearAttentionForSession(sessionID, factoryID);', + ' rememberProvisionalBusy(sessionID, factoryID);', + ' await publishAggregateStatus(factoryID, sessionID);', + ' return;', + ' }', + ' if (event.type === "permission.asked" || event.type === "question.asked") {', + ' stateArrivalRevision += 1;', + ' // Why: attention must share the lifecycle FIFO and retry target so a', + ' // delayed Busy post cannot overwrite a newer human blocker.', + ' const hookEventName =', + ' event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion";', + ' // Why: show the blocker on the root turn while retaining its real child', + ' // owner for exact reply, tool-completion, and disposal cleanup.', + ' const properties = { ...(event.properties || {}), sessionID: rootSessionID || sessionID };', + ' const requestID = properties.id || sessionID || "";', + ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', + ' // Why: unresolved blockers are live UI authority and cannot be evicted;', + ' // reply, exact Idle, tool completion, or factory disposal retires them.', + ' pendingAttentionByKey.set(key, {', + ' hookEventName,', + ' properties,', + ' factoryID,', + ' sourceSessionID: sessionID,', + ' });', + ' await publishAggregateStatus(factoryID, rootSessionID || sessionID);', + ' return;', + ' }', + ' if (isIdleEvent) {', + ' stateArrivalRevision += 1;', + ' const idleKey = "idle:" + (sessionID || "");', + ' // Why: current OpenCode emits canonical idle followed by deprecated', + ' // session.idle; a failed canonical post should keep its backoff.', + ' if (', + ' event.type === "session.idle" &&', + ' desiredStatusKey === idleKey &&', + ' statusDeliveryDirty &&', + ' statusRetryTimer', + ' ) return;', + ' // Why: flush the coalesced final reply snapshot before the idle', + ' // transition so the done-state preview shows the completed message.', + ' await flushPendingAssistantPart(true);', + ' clearAttentionForSession(sessionID, factoryID);', + ' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' }', + ' await publishAggregateStatus(factoryID, sessionID);', + ' return;', + ' }', + ' // Why: recoverable compaction failures emit session.error and continue;', + ' // canonical session.status is the authority for actual completion.', + ' if (event.type === "session.error") return;', + ' if (statusType === "busy" || statusType === "retry") {', + ' clearAttentionForSession(sessionID, factoryID);', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' await publishAggregateStatus(factoryID, sessionID);', + ' }', '}', '', '// Why: accept the factory argument as an optional opaque parameter instead', @@ -276,9 +812,14 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { '// UnknownError before any event is ever dispatched.', 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', ' const client = _ctx?.client;', + ' const factoryID = ++nextFactoryID;', + ' activeFactoryIDs.add(factoryID);', + ' let disposed = false;', ' return {', ' event: async ({ event }) => {', - ' if (!event?.type) return;', + ' if (disposed || !event?.type) return;', + ' const authorityRevision = stateArrivalRevision;', + ' const statusType = getStatusType(event);', '', ' // Why: cache the message role BEFORE the async isChildSession check.', ' // OpenCode fires message.updated (user) and message.part.updated (text)', @@ -293,30 +834,59 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' }', '', ' const sessionID = event.properties?.sessionID;', - ' if (sessionID && (await isChildSession(client, sessionID))) {', + ' const updatedPart = event.properties?.part;', + ' if (', + ' event.type === "message.part.updated" &&', + ' updatedPart?.type === "tool" &&', + ' updatedPart.tool === "question" &&', + ' (updatedPart.state?.status === "completed" || updatedPart.state?.status === "error")', + ' ) {', + ' await enqueueLifecycle(async () => {', + ' if (disposed) return;', + ' // Why: stored ownership clears child questions without waiting on', + ' // ancestry lookup even though ordinary child message parts stay hidden.', + ' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);', + ' if (!rootSessionID) return;', + ' await publishOwnershipChange(factoryID, rootSessionID);', + ' });', ' return;', ' }', '', - ' if (event.type === "permission.asked") {', - ' // Why: permission asks are not a session state transition — emit', - ' // without mutating lastStatus so the next SessionBusy/SessionIdle', - ' // still fires. The server maps PermissionRequest to `waiting`.', - ' await post("PermissionRequest", event.properties || {});', + ' if (', + ' event.type === "session.status" ||', + ' event.type === "session.idle" ||', + ' event.type === "session.error" ||', + ' event.type === "permission.asked" ||', + ' event.type === "question.asked" ||', + ' event.type === "permission.replied" ||', + ' event.type === "question.replied" ||', + ' event.type === "question.rejected"', + ' ) {', + ' await enqueueLifecycle(() =>', + ' disposed ? undefined : handleLifecycleEvent(client, event, factoryID)', + ' );', ' return;', ' }', '', - ' if (event.type === "question.asked") {', - ' // Why: question.asked fires when OpenCode uses an ask-the-user tool', - ' // (distinct from permission.asked, which blocks on tool approval).', - ' // The agent is idle-but-waiting on a human reply, not running, so we', - ' // must flip the pane to the same red "needs attention" state used for', - ' // permission requests. Like permission.asked, do not touch lastStatus', - ' // so the next SessionBusy/SessionIdle after the user answers still', - ' // fires and restores the normal working/done flow.', - ' await post("AskUserQuestion", event.properties || {});', + ' if (event.type === "message.part.delta") {', + ' const properties = event.properties || {};', + ' if (', + ' properties.field === "text" &&', + ' typeof properties.delta === "string" &&', + ' properties.delta.length > 0', + ' ) {', + ' await recoverBusyFromDelta(client, sessionID, factoryID);', + ' }', ' return;', ' }', '', + ' if (sessionID && (await isChildSession(client, sessionID)) !== false) {', + ' return;', + ' }', + ' if (disposed) return;', + ' if (authorityRevision !== stateArrivalRevision) return;', + ' if (desiredStatus === "waiting") return;', + '', ' if (event.type === "message.updated") {', ' // Why: role is already cached above the isChildSession await so the', ' // back-to-back message.part.updated for the same messageID is not', @@ -343,31 +913,84 @@ export function getOpenCodeFamilyPluginSource(hookPathname: string): string { ' // Why: user prompts arrive as a single event, not a stream — post', ' // immediately (still capped) so the throttle slot stays free for', ' // the assistant reply that follows within the same window.', - ' await post("MessagePart", { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID });', + ' await postMessagePart(', + ' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },', + ' factoryID', + ' );', ' return;', ' }', - ' queueAssistantPart({ role, text: part.text, messageID: part.messageID, sessionID });', + ' queueAssistantPart({', + ' role,', + ' text: part.text,', + ' messageID: part.messageID,', + ' sessionID,', + ' authorityRevision,', + ' factoryID,', + ' });', ' return;', ' }', '', - ' if (event.type === "session.idle" || event.type === "session.error") {', - ' // Why: flush the coalesced final reply snapshot before the idle', - ' // transition so the done-state preview shows the completed message.', - ' await flushPendingAssistantPart();', - ' await setStatus("idle", { sessionID });', - ' return;', - ' }', - '', - ' if (event.type === "session.status") {', - ' const statusType = getStatusType(event);', - ' if (statusType === "busy" || statusType === "retry") {', - ' await setStatus("busy", { sessionID });', - ' return;', + ' },', + ' dispose: async () => {', + ' if (disposed) return;', + ' disposed = true;', + ' disposingFactoryIDs.add(factoryID);', + ' await enqueueLifecycle(async () => {', + ' // An older MessagePart must settle before disposal publishes the', + ' // replacement state, or its late Working update could win.', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {', + ' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);', ' }', - ' if (statusType === "idle") {', - ' await setStatus("idle", { sessionID });', + ' for (const [key, provisional] of provisionalBusyByKey) {', + ' if (provisional.factoryID === factoryID) provisionalBusyByKey.delete(key);', ' }', - ' }', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' if (attention.factoryID === factoryID) pendingAttentionByKey.delete(key);', + ' }', + ' if (pendingAssistantPart?.factoryID === factoryID) {', + ' if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer);', + ' assistantPartFlushTimer = null;', + ' pendingAssistantPart = null;', + ' }', + ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', + ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', + ' clearStatusRetry();', + ' statusRevision += 1;', + ' // A MessagePart may have changed the listener to Working after the', + ' // same lifecycle key was delivered; force that key to be reasserted.', + ' statusDeliveryDirty = ownsDeliveredMessagePart;', + ' busyRecoveryUsed = false;', + ' busyRecoveryEndpointKey = "";', + ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', + ' (id) => id !== factoryID', + ' );', + ' if (fallbackFactoryID !== undefined) {', + ' await publishAggregateStatus(', + ' fallbackFactoryID,', + ' desiredStatusProperties?.sessionID', + ' );', + ' } else {', + ' // Why: Instance disposal can happen while the PTY stays alive;', + ' // publish a final idle so Orca does not retain a dead owner.', + ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', + ' await setStatus(', + ' "idle",', + ' { sessionID: desiredStatusProperties?.sessionID },', + ' factoryID', + ' );', + ' }', + ' clearStatusRetry();', + ' desiredStatus = "idle";', + ' desiredHookEventName = "SessionIdle";', + ' desiredStatusKey = "idle:";', + ' desiredStatusProperties = {};', + ' desiredFactoryID = null;', + ' }', + ' }', + ' activeFactoryIDs.delete(factoryID);', + ' disposingFactoryIDs.delete(factoryID);', + ' });', ' },', ' };', '};', From 48e31b3fc0ae70689d27dc5bd2c10b14e75afc7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:50:25 -0700 Subject: [PATCH 17/53] fix(agent-status): show Codex v2 subagents (#11059) * fix(agent-status): track Codex rollout subagents * fix(agent-status): resolve cross-day Codex child rollouts and unblock CI gate Codex files each rollout under its own local start date, so a session that runs past midnight spawns children into a sibling day directory. Scanning only the parent's directory left 13% of real subagent spawns (48/371 across local rollouts) permanently unresolved, which pinned a phantom "working" row and re-ran readdirSync every poll tick forever. Resolve the child's own day directory from occurred_at_ms, and time-box a child whose rollout stays unreadable so a deleted or never-written file can't leak a working row. Also make the hook HTTP handler return void: the changed-code quality gate keys findings by span overlap, so this PR's added line inside the pre-existing async createServer callback resurfaced no-misused-promises as a new finding. Tests cover cross-day resolution, grace-period retirement, and that the poll re-arms across successive roster changes (the prior tests passed even when the poll died after its first change). * fix(agent-status): keep the Codex subagent poll alive across nested hooks A nested non-codex CLI inherits its parent's ORCA_PANE_KEY, so its hook POST reached scheduleCodexSubagentPoll and tore the timer down before the source guard, silently ending polling while a rollout child was still live. --- .../server-codex-subagent-transcript.test.ts | 207 ++++++++++++ src/main/agent-hooks/server.ts | 62 +++- ...k-server-codex-subagent-transcript.test.ts | 85 +++++ src/relay/agent-hook-server.ts | 56 ++++ src/shared/agent-hook-listener.ts | 42 ++- .../codex-subagent-rollout-lifecycle.test.ts | 92 ++++++ src/shared/codex-subagent-roster.test.ts | 2 + src/shared/codex-subagent-roster.ts | 14 +- src/shared/codex-subagent-transcript.test.ts | 158 +++++++++ src/shared/codex-subagent-transcript.ts | 299 ++++++++++++++++++ 10 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 src/main/agent-hooks/server-codex-subagent-transcript.test.ts create mode 100644 src/relay/agent-hook-server-codex-subagent-transcript.test.ts create mode 100644 src/shared/codex-subagent-rollout-lifecycle.test.ts create mode 100644 src/shared/codex-subagent-transcript.test.ts create mode 100644 src/shared/codex-subagent-transcript.ts diff --git a/src/main/agent-hooks/server-codex-subagent-transcript.test.ts b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts new file mode 100644 index 00000000000..dd771d8b2be --- /dev/null +++ b/src/main/agent-hooks/server-codex-subagent-transcript.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { AgentHookServer } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' +const SECOND_CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f3170' + +function line(record: unknown): string { + return `${JSON.stringify(record)}\n` +} + +function spawnLine(threadId: string, agentPath: string): string { + return line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: threadId, + agent_path: agentPath, + kind: 'started' + } + }) +} + +describe('AgentHookServer Codex subagent transcript polling', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('publishes rollout-only children and removes them after their task completes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: CHILD_ID, + agent_path: '/root/pr_review', + kind: 'started' + } + }) + ) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + + expect(response.status).toBe(204) + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([ + expect.objectContaining({ id: CHILD_ID, description: '/root/pr_review' }) + ]) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 2_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + // Why: the poll re-arms off the object it just stored; if that identity ever drifts it stops after the first change. + it('keeps polling across successive roster changes', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + const secondChildPath = join(dir, `rollout-child-${SECOND_CHILD_ID}.jsonl`) + const started = line({ type: 'event_msg', payload: { type: 'task_started' } }) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(childPath, started) + writeFileSync(secondChildPath, started) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) + + appendFileSync(parentPath, spawnLine(SECOND_CHILD_ID, '/root/perf_audit')) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(2) + }, + { timeout: 3_000, interval: 50 } + ) + + const complete = line({ type: 'event_msg', payload: { type: 'task_complete' } }) + appendFileSync(childPath, complete) + appendFileSync(secondChildPath, complete) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) + + // Why: a nested non-codex CLI inherits the pane's ORCA_PANE_KEY, so its hook must not tear down the codex poll. + it('keeps polling when a nested non-codex hook lands on the same pane', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, spawnLine(CHILD_ID, '/root/pr_review')) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const post = (path: string, payload: unknown): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify({ paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', payload }) + }) + + await post('/hook/codex', { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + }) + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) + + const nested = await post('/hook/copilot', { + hook_event_name: 'Stop', + session_id: 'nested-session', + transcript_path: join(dir, 'nested-copilot-transcript.jsonl') + }) + expect(nested.status).toBe(204) + // The nested completion is suppressed, so the pane is still the same live codex turn. + expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex') + expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(1) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.waitFor( + () => { + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }, + { timeout: 3_000, interval: 50 } + ) + } finally { + server.stop() + } + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index a9395cf44de..8887a285235 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -15,6 +15,7 @@ import { clearClaudeAnsweredQuestionWait, createHookListenerState, getEndpointFileName, + hasCodexTranscriptSubagents, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, markClaudeLeadTurnInterrupted, @@ -106,6 +107,7 @@ type PaneKeyAliasEntry = { const LAST_STATUS_FILE_NAME = 'last-status.json' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 +const CODEX_SUBAGENT_POLL_MS = 1_000 const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 // Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). @@ -484,6 +486,7 @@ export class AgentHookServer { // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. private statusPersistTimer: ReturnType | null = null private assistantMessageRetryTimers = new Map>() + private codexSubagentPollTimers = new Map>() private promptSentDedupeByPaneKey = new Map() private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() @@ -1040,6 +1043,49 @@ export class AgentHookServer { this.assistantMessageRetryTimers.delete(paneKey) } + private clearCodexSubagentPoll(paneKey: string): void { + const timer = this.codexSubagentPollTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.codexSubagentPollTimers.delete(paneKey) + } + + private scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.clearCodexSubagentPoll(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + const timer = setTimeout(() => { + this.codexSubagentPollTimers.delete(original.paneKey) + const current = this.state.lastStatusByPaneKey.get(original.paneKey) + if (!this.server || current !== original) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { + return + } + const subagentsChanged = + JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + this.scheduleCodexSubagentPoll(source, body, next) + }, CODEX_SUBAGENT_POLL_MS) + this.codexSubagentPollTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + private scheduleAssistantMessageRetry( source: AgentHookSource, body: unknown, @@ -1264,6 +1310,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) } this.clearAssistantMessageRetry(previousOwnerPaneKey) + this.clearCodexSubagentPoll(previousOwnerPaneKey) // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. this.legacyPaneKeyAliases.set(physicalPaneKey, { stablePaneKey: toPaneKey, @@ -1296,6 +1343,7 @@ export class AgentHookServer { for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) this.clearAssistantMessageRetry(key) + this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) this.runtimeObservedStatusPaneKeys.delete(key) this.promptSentDedupeByPaneKey.delete(key) @@ -1584,7 +1632,7 @@ export class AgentHookServer { if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() } - this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { res.writeHead(404) res.end() @@ -1627,6 +1675,7 @@ export class AgentHookServer { if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) { const enriched = this.applyNormalizedStatus(normalized) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) } res.writeHead(204) @@ -1636,6 +1685,10 @@ export class AgentHookServer { res.writeHead(204) res.end() } + } + // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + this.server = createServer((req, res) => { + void handleRequest(req, res) }) await new Promise((resolve, reject) => { @@ -1675,6 +1728,10 @@ export class AgentHookServer { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() + for (const timer of this.codexSubagentPollTimers.values()) { + clearTimeout(timer) + } + this.codexSubagentPollTimers.clear() // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. this.endpointDir = null this.endpointFilePathCache = null @@ -1751,6 +1808,7 @@ export class AgentHookServer { } this.state.lastStatusByPaneKey.delete(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) if (existing.payload.state === 'done') { this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) @@ -1816,6 +1874,7 @@ export class AgentHookServer { statusChanged = true } this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.runtimeObservedStatusPaneKeys.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) @@ -1834,6 +1893,7 @@ export class AgentHookServer { // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) clearPaneCacheState(this.state, resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) let clearedAlias = false diff --git a/src/relay/agent-hook-server-codex-subagent-transcript.test.ts b/src/relay/agent-hook-server-codex-subagent-transcript.test.ts new file mode 100644 index 00000000000..e9d20d51db2 --- /dev/null +++ b/src/relay/agent-hook-server-codex-subagent-transcript.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { makePaneKey } from '../shared/stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' + +function line(record: unknown): string { + return `${JSON.stringify(record)}\n` +} + +describe('RelayAgentHookServer Codex subagent transcript polling', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('forwards completion discovered from a child rollout', async () => { + const dir = mkdtempSync(join(tmpdir(), 'relay-hook-codex-subagent-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + line({ + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: CHILD_ID, + agent_path: '/root/pr_review', + kind: 'started' + } + }) + ) + writeFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_started' } })) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const response = await fetch(`http://127.0.0.1:${port}/hook/codex`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { + hook_event_name: 'PostToolUse', + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }) + }) + + expect(response.status).toBe(204) + expect(forward.mock.calls[0]?.[0].payload.subagents).toHaveLength(1) + + appendFileSync(childPath, line({ type: 'event_msg', payload: { type: 'task_complete' } })) + await vi.waitFor( + () => { + expect(forward.mock.calls.at(-1)?.[0].payload.subagents).toBeUndefined() + expect(forward).toHaveBeenCalledTimes(2) + }, + { timeout: 2_000, interval: 50 } + ) + } finally { + server.stop() + } + }) +}) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 40775b5bd6a..fb938f55d2b 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -14,6 +14,7 @@ import { clearPaneCacheState, createHookListenerState, getEndpointFileName, + hasCodexTranscriptSubagents, hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, @@ -37,6 +38,7 @@ const RELAY_HOOKS_DIR_NAME = '.orca-relay' const RELAY_HOOKS_SUBDIR = 'agent-hooks' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 const ASSISTANT_MESSAGE_RETRY_MS = 50 +const CODEX_SUBAGENT_POLL_MS = 1_000 // Why: cap env/version at 64 chars so a misbehaving agent CLI can't grow the meta cache unboundedly; canonical values are short. const MAX_HOOK_META_LEN = 64 @@ -101,6 +103,7 @@ export class RelayAgentHookServer { { source: AgentHookSource; env?: string; version?: string } > = new Map() private assistantMessageRetryTimers = new Map>() + private codexSubagentPollTimers = new Map>() private forward: RelayHookForward private fixedToken: string | undefined private preferredPort: number @@ -193,6 +196,10 @@ export class RelayAgentHookServer { clearTimeout(timer) } this.assistantMessageRetryTimers.clear() + for (const timer of this.codexSubagentPollTimers.values()) { + clearTimeout(timer) + } + this.codexSubagentPollTimers.clear() clearAllListenerCaches(this.state) this.lastEnvelopeMetaByPaneKey.clear() } @@ -216,6 +223,7 @@ export class RelayAgentHookServer { /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) clearPaneCacheState(this.state, paneKey) this.lastEnvelopeMetaByPaneKey.delete(paneKey) } @@ -274,6 +282,7 @@ export class RelayAgentHookServer { const version = this.bodyVersion(body) this.applyEvent(event, source, env, version) this.scheduleAssistantMessageRetry(source, body, event, env, version) + this.scheduleCodexSubagentPoll(source, body, event, env, version) } res.writeHead(204) res.end() @@ -350,6 +359,53 @@ export class RelayAgentHookServer { this.assistantMessageRetryTimers.delete(paneKey) } + private clearCodexSubagentPoll(paneKey: string): void { + const timer = this.codexSubagentPollTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.codexSubagentPollTimers.delete(paneKey) + } + + private scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: AgentHookEventPayload, + env?: string, + version?: string + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.clearCodexSubagentPoll(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + const timer = setTimeout(() => { + this.codexSubagentPollTimers.delete(original.paneKey) + if (!this.server || this.state.lastStatusByPaneKey.get(original.paneKey) !== original) { + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (!event) { + return + } + const subagentsChanged = + JSON.stringify(event.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? event : original + if (subagentsChanged) { + this.applyEvent(event, source, env, version) + } + this.scheduleCodexSubagentPoll(source, body, next, env, version) + }, CODEX_SUBAGENT_POLL_MS) + this.codexSubagentPollTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + private scheduleAssistantMessageRetry( source: AgentHookSource, body: unknown, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index d26268d120e..ee3536bb932 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -45,6 +45,12 @@ import { upsertCodexSubagent, type CodexSubagentRoster } from './codex-subagent-roster' +import { + createCodexSubagentTranscriptState, + hasTrackedCodexTranscriptSubagents, + reconcileCodexSubagentTranscript, + type CodexSubagentTranscriptState +} from './codex-subagent-transcript' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' import { @@ -111,6 +117,8 @@ export type HookListenerState = { claudeLeadStateByPaneKey: Map /** Live thread-spawn children per Codex pane. */ codexSubagentRosterByPaneKey: Map + /** Incremental parent/child rollout cursors for Codex collaboration v2. */ + codexSubagentTranscriptByPaneKey: Map /** Root Codex state/model, kept separate from child hook traffic. */ codexLeadStateByPaneKey: Map } @@ -141,6 +149,7 @@ export function createHookListenerState(): HookListenerState { claudeSubagentRosterByPaneKey: new Map(), claudeLeadStateByPaneKey: new Map(), codexSubagentRosterByPaneKey: new Map(), + codexSubagentTranscriptByPaneKey: new Map(), codexLeadStateByPaneKey: new Map() } } @@ -154,6 +163,7 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeLeadStateByPaneKey.delete(paneKey) state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexSubagentTranscriptByPaneKey.delete(paneKey) state.codexLeadStateByPaneKey.delete(paneKey) } @@ -197,6 +207,7 @@ export function movePaneCacheState( movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentTranscriptByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) } @@ -238,6 +249,7 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.claudeSubagentRosterByPaneKey.clear() state.claudeLeadStateByPaneKey.clear() state.codexSubagentRosterByPaneKey.clear() + state.codexSubagentTranscriptByPaneKey.clear() state.codexLeadStateByPaneKey.clear() } @@ -3098,6 +3110,22 @@ function getOrCreateCodexSubagentRoster( return roster } +function getOrCreateCodexSubagentTranscriptState( + state: HookListenerState, + paneKey: string +): CodexSubagentTranscriptState { + let transcriptState = state.codexSubagentTranscriptByPaneKey.get(paneKey) + if (!transcriptState) { + transcriptState = createCodexSubagentTranscriptState() + state.codexSubagentTranscriptByPaneKey.set(paneKey, transcriptState) + } + return transcriptState +} + +export function hasCodexTranscriptSubagents(state: HookListenerState, paneKey: string): boolean { + return hasTrackedCodexTranscriptSubagents(state.codexSubagentTranscriptByPaneKey.get(paneKey)) +} + export function seedCodexStateFromSnapshot( state: HookListenerState, paneKey: string, @@ -3178,7 +3206,7 @@ export function reconcileRemoteCodexState( } } else { const leadState = codexLeadStateForHookEvent(eventName) - if (eventName === 'SessionStart' || eventName === 'Stop') { + if (eventName === 'SessionStart' || (eventName === 'Stop' && !payload.subagents)) { roster.clear() } if (leadState) { @@ -3325,7 +3353,17 @@ function normalizeCodexEvent( if (eventName === 'SessionStart') { // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. state.codexSubagentRosterByPaneKey.delete(paneKey) - } else if (eventName === 'Stop') { + state.codexSubagentTranscriptByPaneKey.delete(paneKey) + } + const transcriptPath = readFirstString(hookPayload, ['transcript_path', 'transcriptPath']) + if (transcriptPath) { + reconcileCodexSubagentTranscript( + getOrCreateCodexSubagentTranscriptState(state, paneKey), + getOrCreateCodexSubagentRoster(state, paneKey), + transcriptPath + ) + } + if (eventName === 'Stop' && !hasCodexTranscriptSubagents(state, paneKey)) { // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. state.codexSubagentRosterByPaneKey.delete(paneKey) } diff --git a/src/shared/codex-subagent-rollout-lifecycle.test.ts b/src/shared/codex-subagent-rollout-lifecycle.test.ts new file mode 100644 index 00000000000..9d476b85504 --- /dev/null +++ b/src/shared/codex-subagent-rollout-lifecycle.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { createHookListenerState, normalizeHookPayload } from './agent-hook-listener' +import { makePaneKey } from './stable-pane-id' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +describe('Codex rollout subagent lifecycle', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('surfaces a collaboration child without lifecycle hooks and retires it on task_complete', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-rollout-lifecycle-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync( + parentPath, + jsonl([ + { + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: 1234, + agent_thread_id: CHILD_ID, + agent_path: '/root/pr_review', + kind: 'started' + } + } + ]) + ) + writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) + const state = createHookListenerState() + const event = (hookEventName: string): ReturnType => + normalizeHookPayload( + state, + 'codex', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: hookEventName, + session_id: 'root-session', + transcript_path: parentPath, + tool_name: 'collaborationspawn_agent' + } + }, + 'production' + ) + + const spawned = event('PostToolUse') + expect(spawned?.payload).toMatchObject({ + state: 'working', + subagents: [ + { + id: CHILD_ID, + description: '/root/pr_review', + state: 'working', + startedAt: 1234 + } + ] + }) + + const leadStopped = event('Stop') + expect(leadStopped?.payload.state).toBe('working') + expect(leadStopped?.payload.subagents).toHaveLength(1) + + writeFileSync( + childPath, + jsonl([ + { type: 'event_msg', payload: { type: 'task_started' } }, + { type: 'event_msg', payload: { type: 'task_complete' } } + ]) + ) + const completed = event('Stop') + expect(completed?.payload.state).toBe('done') + expect(completed?.payload.subagents).toBeUndefined() + }) +}) diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts index ebb947dedf6..fde2c8cf849 100644 --- a/src/shared/codex-subagent-roster.test.ts +++ b/src/shared/codex-subagent-roster.test.ts @@ -20,6 +20,7 @@ describe('Codex subagent roster', () => { ' child-1 ', { agentType: `reviewer\n${'x'.repeat(AGENT_TYPE_MAX_LENGTH * 2)}`, + description: 'Review the sidebar lifecycle', model: `gpt-model-${'x'.repeat(AGENT_MODEL_MAX_LENGTH * 2)}`, state: 'working' }, @@ -30,6 +31,7 @@ describe('Codex subagent roster', () => { expect([...roster.keys()]).toEqual(['child-1']) expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH) expect(snapshot?.agentType).not.toContain('\n') + expect(snapshot?.description).toBe('Review the sidebar lifecycle') expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH) finishCodexSubagent(roster, ' child-1 ') diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts index 5c4d3e6d7a0..72cc216b76e 100644 --- a/src/shared/codex-subagent-roster.ts +++ b/src/shared/codex-subagent-roster.ts @@ -1,6 +1,7 @@ import { AGENT_MODEL_MAX_LENGTH, AGENT_STATUS_MAX_SUBAGENTS, + AGENT_STATUS_TOOL_INPUT_MAX_LENGTH, AGENT_TYPE_MAX_LENGTH, type AgentSubagentSnapshot } from './agent-status-types' @@ -12,6 +13,7 @@ export type CodexSubagentRoster = Map type TrackedCodexSubagent = { agentType?: string + description?: string model?: string state: 'working' | 'waiting' startedAt: number @@ -22,6 +24,7 @@ export function upsertCodexSubagent( id: string, fields: { agentType?: string + description?: string model?: string state: 'working' | 'waiting' }, @@ -32,10 +35,12 @@ export function upsertCodexSubagent( return } const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH) + const description = normalizeOptionalField(fields.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH) const existing = roster.get(normalizedId) if (existing) { existing.agentType = agentType ?? existing.agentType + existing.description = description ?? existing.description existing.model = model ?? existing.model existing.state = fields.state return @@ -45,6 +50,7 @@ export function upsertCodexSubagent( } roster.set(normalizedId, { agentType, + description, model, state: fields.state, startedAt: now @@ -66,7 +72,12 @@ export function seedCodexSubagentRoster( upsertCodexSubagent( roster, snapshot.id, - { agentType: snapshot.agentType, model: snapshot.model, state: snapshot.state }, + { + agentType: snapshot.agentType, + description: snapshot.description, + model: snapshot.model, + state: snapshot.state + }, snapshot.startedAt ) } @@ -81,6 +92,7 @@ export function codexRosterToSnapshots( const snapshots = Array.from(roster, ([id, tracked]) => ({ id, agentType: tracked.agentType, + description: tracked.description, model: tracked.model, state: tracked.state, startedAt: tracked.startedAt diff --git a/src/shared/codex-subagent-transcript.test.ts b/src/shared/codex-subagent-transcript.test.ts new file mode 100644 index 00000000000..d2f729912b5 --- /dev/null +++ b/src/shared/codex-subagent-transcript.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + createCodexSubagentTranscriptState, + hasTrackedCodexTranscriptSubagents, + reconcileCodexSubagentTranscript +} from './codex-subagent-transcript' +import { codexRosterToSnapshots, type CodexSubagentRoster } from './codex-subagent-roster' + +const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' + +function jsonl(records: unknown[]): string { + return `${records.map((record) => JSON.stringify(record)).join('\n')}\n` +} + +function activity(kind: string, occurredAtMs = 1234): unknown { + return { + type: 'event_msg', + payload: { + type: 'sub_agent_activity', + occurred_at_ms: occurredAtMs, + agent_thread_id: CHILD_ID, + agent_path: '/root/sidebar_repro', + kind + } + } +} + +/** `/YYYY/MM/DD` for a timestamp, matching how Codex buckets rollouts by local start date. */ +function dayDirectory(root: string, atMs: number): string { + const at = new Date(atMs) + const pad = (value: number): string => String(value).padStart(2, '0') + return join(root, String(at.getFullYear()), pad(at.getMonth() + 1), pad(at.getDate())) +} + +describe('Codex subagent transcript reconciliation', () => { + const dirs: string[] = [] + + afterEach(() => { + for (const dir of dirs) { + rmSync(dir, { recursive: true, force: true }) + } + dirs.length = 0 + }) + + it('adds a child from the parent rollout and removes it after task completion', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + const childPath = join(dir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, jsonl([activity('started')])) + writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + + reconcileCodexSubagentTranscript(state, roster, parentPath) + + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(true) + expect(codexRosterToSnapshots(roster)).toEqual([ + { + id: CHILD_ID, + description: '/root/sidebar_repro', + state: 'working', + startedAt: 1234, + agentType: undefined, + model: undefined + } + ]) + + writeFileSync( + childPath, + jsonl([ + { type: 'event_msg', payload: { type: 'task_started' } }, + { type: 'event_msg', payload: { type: 'task_complete' } } + ]) + ) + reconcileCodexSubagentTranscript(state, roster, parentPath) + + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + expect(codexRosterToSnapshots(roster)).toBeUndefined() + }) + + it('resolves a child rollout filed under a later session day than the parent', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(root) + const childStartedAt = Date.now() + const parentDir = dayDirectory(root, childStartedAt - 24 * 60 * 60 * 1000) + const childDir = dayDirectory(root, childStartedAt) + mkdirSync(parentDir, { recursive: true }) + mkdirSync(childDir, { recursive: true }) + const parentPath = join(parentDir, 'rollout-parent.jsonl') + const childPath = join(childDir, `rollout-child-${CHILD_ID}.jsonl`) + writeFileSync(parentPath, jsonl([activity('started', childStartedAt)])) + writeFileSync(childPath, jsonl([{ type: 'event_msg', payload: { type: 'task_started' } }])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + + reconcileCodexSubagentTranscript(state, roster, parentPath) + writeFileSync( + childPath, + jsonl([ + { type: 'event_msg', payload: { type: 'task_started' } }, + { type: 'event_msg', payload: { type: 'task_complete' } } + ]) + ) + reconcileCodexSubagentTranscript(state, roster, parentPath) + + // Why: only a cross-day lookup can observe the completion; the parent-directory scan never finds this file. + expect(roster.size).toBe(0) + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + }) + + it('retires a child whose rollout never becomes readable', () => { + vi.useFakeTimers() + try { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + writeFileSync(parentPath, jsonl([activity('started')])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + + reconcileCodexSubagentTranscript(state, roster, parentPath) + expect(roster.size).toBe(1) + + // Why: within the grace window a slow-to-appear rollout must not drop a live child. + vi.advanceTimersByTime(30_000) + reconcileCodexSubagentTranscript(state, roster, parentPath) + expect(roster.size).toBe(1) + + vi.advanceTimersByTime(31_000) + reconcileCodexSubagentTranscript(state, roster, parentPath) + expect(roster.size).toBe(0) + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('removes a child when Codex reports it interrupted', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-')) + dirs.push(dir) + const parentPath = join(dir, 'rollout-parent.jsonl') + writeFileSync(parentPath, jsonl([activity('started')])) + const state = createCodexSubagentTranscriptState() + const roster: CodexSubagentRoster = new Map() + reconcileCodexSubagentTranscript(state, roster, parentPath) + + writeFileSync(parentPath, jsonl([activity('started'), activity('interrupted')])) + reconcileCodexSubagentTranscript(state, roster, parentPath) + + expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) + expect(roster.size).toBe(0) + }) +}) diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts new file mode 100644 index 00000000000..3f9b30898c1 --- /dev/null +++ b/src/shared/codex-subagent-transcript.ts @@ -0,0 +1,299 @@ +import { closeSync, openSync, readSync, readdirSync, statSync, type Stats } from 'node:fs' +import { basename, dirname, extname, isAbsolute, join } from 'node:path' + +import { + finishCodexSubagent, + upsertCodexSubagent, + type CodexSubagentRoster +} from './codex-subagent-roster' + +const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024 +const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024 +const TRANSCRIPT_DIRECTORY_MAX_ENTRIES = 4096 +// Why: retire a child whose rollout stays unreadable this long, else a deleted/never-written file pins a phantom row forever. +const CHILD_UNREADABLE_GRACE_MS = 60_000 +const SAFE_THREAD_ID = /^[A-Za-z0-9-]{1,64}$/ + +type JsonlCursor = { + filePath?: string + offset: number + carry: string +} + +type TrackedTranscriptSubagent = JsonlCursor & { + description?: string + startedAt: number + unresolvedSince?: number +} + +export type CodexSubagentTranscriptState = { + parent: JsonlCursor + subagents: Map +} + +type JsonRecord = Record + +function record(value: unknown): JsonRecord | undefined { + return typeof value === 'object' && value !== null ? (value as JsonRecord) : undefined +} + +/** Returns undefined when the file is unreadable, distinguishing a vanished rollout from one with no new lines. */ +function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { + if (!cursor.filePath) { + return undefined + } + let stats: Stats + try { + stats = statSync(cursor.filePath) + } catch { + return undefined + } + if (!stats.isFile()) { + return undefined + } + if (stats.size < cursor.offset) { + cursor.offset = 0 + cursor.carry = '' + } + if (stats.size === cursor.offset) { + return [] + } + const bytesToRead = Math.min(stats.size - cursor.offset, TRANSCRIPT_READ_MAX_BYTES) + const start = stats.size - cursor.offset > bytesToRead ? stats.size - bytesToRead : cursor.offset + const buffer = Buffer.allocUnsafe(bytesToRead) + let bytesRead = 0 + let fd: number | undefined + try { + fd = openSync(cursor.filePath, 'r') + bytesRead = readSync(fd, buffer, 0, bytesToRead, start) + } catch { + return undefined + } finally { + if (fd !== undefined) { + closeSync(fd) + } + } + const skippedPrefix = start !== cursor.offset + const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}` + const lines = content.split('\n') + cursor.offset = start + bytesRead + cursor.carry = lines.pop() ?? '' + if (skippedPrefix) { + lines.shift() + } + const records: JsonRecord[] = [] + for (const line of lines) { + if (Buffer.byteLength(line, 'utf8') > TRANSCRIPT_LINE_MAX_BYTES) { + continue + } + try { + const parsed = record(JSON.parse(line) as unknown) + if (parsed) { + records.push(parsed) + } + } catch { + // A malformed rollout line must not block later lifecycle events. + } + } + return records +} + +function readTranscriptDirectory(directory: string): string[] { + let entries: string[] + try { + entries = readdirSync(directory) + } catch { + return [] + } + if (entries.length > TRANSCRIPT_DIRECTORY_MAX_ENTRIES) { + entries = entries.slice(-TRANSCRIPT_DIRECTORY_MAX_ENTRIES) + } + return entries +} + +// Why: Codex files each rollout under its OWN local start date, so a session running past midnight spawns children into a sibling day directory. +function childDayDirectory(parentPath: string, startedAt: number): string | undefined { + const dayDir = dirname(parentPath) + const monthDir = dirname(dayDir) + const yearDir = dirname(monthDir) + if ( + !/^\d{2}$/.test(basename(dayDir)) || + !/^\d{2}$/.test(basename(monthDir)) || + !/^\d{4}$/.test(basename(yearDir)) || + !Number.isFinite(startedAt) + ) { + return undefined + } + const startedOn = new Date(startedAt) + if (Number.isNaN(startedOn.getTime())) { + return undefined + } + const pad = (value: number): string => String(value).padStart(2, '0') + return join( + dirname(yearDir), + String(startedOn.getFullYear()).padStart(4, '0'), + pad(startedOn.getMonth() + 1), + pad(startedOn.getDate()) + ) +} + +function resolveChildTranscript( + parentPath: string, + threadId: string, + startedAt: number, + entriesByDirectory: Map +): string | undefined { + if (!SAFE_THREAD_ID.test(threadId)) { + return undefined + } + const suffix = `-${threadId}.jsonl` + const parentDir = dirname(parentPath) + const childDir = childDayDirectory(parentPath, startedAt) + const directories = childDir && childDir !== parentDir ? [parentDir, childDir] : [parentDir] + for (const directory of directories) { + let entries = entriesByDirectory.get(directory) + if (!entries) { + entries = readTranscriptDirectory(directory) + entriesByDirectory.set(directory, entries) + } + const fileName = entries.find((entry) => entry.endsWith(suffix)) + if (fileName) { + return join(directory, fileName) + } + } + return undefined +} + +function readActivity(recordValue: JsonRecord): + | { + id: string + description?: string + kind: 'started' | 'interacted' | 'interrupted' + startedAt: number + } + | undefined { + if (recordValue.type !== 'event_msg') { + return undefined + } + const payload = record(recordValue.payload) + if (payload?.type !== 'sub_agent_activity') { + return undefined + } + const id = typeof payload.agent_thread_id === 'string' ? payload.agent_thread_id.trim() : '' + const rawKind = typeof payload.kind === 'string' ? payload.kind.toLowerCase() : '' + if ( + !SAFE_THREAD_ID.test(id) || + (rawKind !== 'started' && rawKind !== 'interacted' && rawKind !== 'interrupted') + ) { + return undefined + } + return { + id, + description: + typeof payload.agent_path === 'string' ? payload.agent_path.trim() || undefined : undefined, + kind: rawKind, + startedAt: + typeof payload.occurred_at_ms === 'number' && Number.isFinite(payload.occurred_at_ms) + ? payload.occurred_at_ms + : Date.now() + } +} + +function childIsComplete(records: JsonRecord[]): boolean { + let complete = false + for (const recordValue of records) { + if (recordValue.type !== 'event_msg') { + continue + } + const payload = record(recordValue.payload) + if (payload?.type === 'task_started') { + complete = false + } else if (payload?.type === 'task_complete') { + complete = true + } + } + return complete +} + +export function createCodexSubagentTranscriptState(): CodexSubagentTranscriptState { + return { + parent: { offset: 0, carry: '' }, + subagents: new Map() + } +} + +export function hasTrackedCodexTranscriptSubagents( + state: CodexSubagentTranscriptState | undefined +): boolean { + return Boolean(state && state.subagents.size > 0) +} + +export function reconcileCodexSubagentTranscript( + state: CodexSubagentTranscriptState, + roster: CodexSubagentRoster, + transcriptPath: string | undefined +): void { + const normalizedPath = transcriptPath?.trim() + if (!normalizedPath || !isAbsolute(normalizedPath) || extname(normalizedPath) !== '.jsonl') { + return + } + if (state.parent.filePath !== normalizedPath) { + for (const id of state.subagents.keys()) { + finishCodexSubagent(roster, id) + } + state.parent = { filePath: normalizedPath, offset: 0, carry: '' } + state.subagents.clear() + } + for (const recordValue of readJsonlCursor(state.parent) ?? []) { + const activity = readActivity(recordValue) + if (!activity) { + continue + } + if (activity.kind === 'interrupted') { + finishCodexSubagent(roster, activity.id) + state.subagents.delete(activity.id) + continue + } + const tracked = state.subagents.get(activity.id) ?? { + offset: 0, + carry: '', + startedAt: activity.startedAt + } + tracked.description = activity.description ?? tracked.description + state.subagents.set(activity.id, tracked) + upsertCodexSubagent( + roster, + activity.id, + { description: tracked.description, state: 'working' }, + tracked.startedAt + ) + } + const entriesByDirectory = new Map() + const now = Date.now() + for (const [id, tracked] of state.subagents) { + if (!tracked.filePath) { + tracked.filePath = resolveChildTranscript( + normalizedPath, + id, + tracked.startedAt, + entriesByDirectory + ) + } + const records = readJsonlCursor(tracked) + if (!records) { + // Why: a rollout that never appears (or is deleted) has no completion event, so time-box it instead of leaking a working row. + tracked.filePath = undefined + tracked.unresolvedSince ??= now + if (now - tracked.unresolvedSince <= CHILD_UNREADABLE_GRACE_MS) { + continue + } + } else { + tracked.unresolvedSince = undefined + if (!childIsComplete(records)) { + continue + } + } + finishCodexSubagent(roster, id) + state.subagents.delete(id) + } +} From 7a3df879941b2b264121e411274ba393f88ac0fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:34 -0700 Subject: [PATCH 18/53] fix(skills): stop offering an update the CLI provably cannot perform (#11110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "fix(skills): stop reporting a failed update when the CLI succeeded (#11105)" This reverts commit a8660839ee734714d4236fd00d960cfde9d97353. * fix(skills): stop offering an update the CLI provably cannot perform The Update skills dialog reported "The update didn't finish" / "Some skills could not be updated" with an armed Retry, directly above the runner's own "All global skills are up to date" log line, on a clean exit 0. `skills update` decides what to do by comparing its lock's `skillFolderHash` against the source tree and never reads disk (published CLI, dist/cli.mjs: `latestHash !== entry.skillFolderHash`). So once the lock records a revision the filesystem does not actually have, it reports up-to-date, exits 0 and writes nothing. No retry converges it. Gate eligibility on that instead of blaming the run afterwards: a name stays updatable only while the lock's hash and the revision the DISK hashes to agree. Those that disagree fall through to `needs-attention`, which skill-freshness-display-status.ts already documents as the state for a copy "out of date somewhere the update command cannot reach". The dialog no longer offers them, so it can neither claim failure nor claim success. Deliberately compared against disk, not the bundled manifest: the updater pulls from the source repo, which legitimately runs ahead of what a build ships, and gating on the bundle would withhold real updates. Both sides must also be positively identified — an unplaceable lock hash is not evidence. Also reverts a8660839ee, which forgave `outdated` post-run. That turned the false failure into a false success ("Updated 2 skills" over copies nothing wrote) and let an empty verdict swallow spawnError, so an offline run published green. Reachable by anyone who updated during the stub-conversion window — every bundled skill has a stub -> full -> stub oscillation in its last three registry revisions. * fix(skills): do not gate a skill when a placement is unidentifiable `diskTreeShas` drops digests matching no known revision, so `every` ran over only the resolved half — one stale copy beside one unidentifiable copy gated the name, contradicting the unknown-stays-eligible rule the function documents. Require every observed digest to resolve before treating all placements as mismatched. Caught by CodeRabbit on #11110. * fix(skills): judge convergence only over placements the update command writes A same-name plugin-cache or repo copy could defeat the gate two ways: an unidentifiable repack read as an unresolved placement, and a cache copy parked at the lock's own revision read as an anchor — both re-arming the unwinnable update on a drifted canonical. Filter to SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, matching eligibility and outcome. --- src/main/skills/skill-freshness-inventory.ts | 12 +- .../skills/skill-update-convergence.test.ts | 178 ++++++++++++++++++ src/main/skills/skill-update-convergence.ts | 73 +++++++ src/main/skills/skill-update-outcome.test.ts | 28 +-- src/main/skills/skill-update-outcome.ts | 18 +- src/main/skills/skill-update-registration.ts | 24 ++- 6 files changed, 287 insertions(+), 46 deletions(-) create mode 100644 src/main/skills/skill-update-convergence.test.ts create mode 100644 src/main/skills/skill-update-convergence.ts diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index 5982c878fc1..41467b11e86 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -17,7 +17,8 @@ import { type CandidateLstat } from './skill-freshness-placement-observation' import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' -import { readGloballyUpdatableSkillNames } from './skill-update-registration' +import { convergableSkillNames } from './skill-update-convergence' +import { readGloballyUpdatableSkillLocks } from './skill-update-registration' export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 @@ -42,9 +43,9 @@ export async function inventorySkillFreshness(args: { candidateLstat?: CandidateLstat stateHome?: string | null }): Promise { - const [artifacts, globallyUpdatableNames] = await Promise.all([ + const [artifacts, globalSkillLocks] = await Promise.all([ loadSkillBundleArtifacts(args.resourceRoot), - readGloballyUpdatableSkillNames({ homeDir: args.homeDir, stateHome: args.stateHome }) + readGloballyUpdatableSkillLocks({ homeDir: args.homeDir, stateHome: args.stateHome }) ]) const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) const discoveryArgs = { @@ -168,7 +169,10 @@ export async function inventorySkillFreshness(args: { return { schemaVersion: 1, installations, - eligibleUpdateNames: eligibleSkillUpdateNames(installations, globallyUpdatableNames), + eligibleUpdateNames: eligibleSkillUpdateNames( + installations, + convergableSkillNames(installations, globalSkillLocks, artifacts.knownSnapshots) + ), scanIssues, scannedAt: Date.now() } diff --git a/src/main/skills/skill-update-convergence.test.ts b/src/main/skills/skill-update-convergence.test.ts new file mode 100644 index 00000000000..86e4f395e6d --- /dev/null +++ b/src/main/skills/skill-update-convergence.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import type { SkillFreshnessInstallation, SkillKnownSnapshot } from '../../shared/skill-freshness' +import { convergableSkillNames } from './skill-update-convergence' + +function placement( + name: string, + observedPackageDigest: string | null, + topology: SkillFreshnessInstallation['topology'] = 'canonical-copy' +): SkillFreshnessInstallation { + return { + id: `${name}:${observedPackageDigest}:${topology}`, + name, + rootId: 'home', + providers: [], + sourceKind: 'home', + sourceLabel: 'home', + unresolvedPath: `~/.agents/skills/${name}`, + resolvedPath: `/home/u/.agents/skills/${name}`, + physicalIdentity: '1:1', + topology, + status: 'outdated', + installedReleaseRevision: null, + installedAppVersion: null, + currentReleaseRevision: 8, + currentPackageDigest: 'digest-current', + currentAppVersion: '1.4.160', + observedPackageDigest, + errorCategory: null + } +} + +function revision(packageDigest: string, gitTreeSha: string): SkillKnownSnapshot { + return { releaseRevision: 1, packageDigest, gitTreeSha, files: [] } +} + +describe('convergableSkillNames', () => { + // The real reported case: the lock records the stub tree (091d9bcc) while disk + // still holds the pre-stub revision (f3727995). `skills update` compares lock to + // source, sees no work, exits 0 and writes nothing — forever. + it('drops a skill whose lock records a revision the disk does not have', () => { + const result = convergableSkillNames( + [placement('orca-linear', 'digest-pre-stub')], + new Map([['orca-linear', '091d9bcc']]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ] + } + ) + expect([...result]).toEqual([]) + }) + + // The legitimate case that must NOT be gated: lock and disk agree, and the source + // has simply moved ahead of what this build bundles. The update really can converge. + it('keeps a skill whose lock matches disk even when it is outdated', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-installed')], + new Map([['orca-cli', 'aaaa1111']]), + { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + it('keeps a skill whose disk content matches no known revision', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-unknown')], + new Map([['orca-cli', 'aaaa1111']]), + { 'orca-cli': [revision('digest-other', 'bbbb2222')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + it('keeps a skill with no observable placement', () => { + const result = convergableSkillNames( + [placement('orca-cli', null)], + new Map([['orca-cli', 'aaaa1111']]), + { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // One placement still matching the lock means the command has an anchor to write. + it('keeps a skill when any placement still matches the lock', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-installed'), placement('orca-cli', 'digest-pre-stub')], + new Map([['orca-cli', 'aaaa1111']]), + { + 'orca-cli': [ + revision('digest-installed', 'aaaa1111'), + revision('digest-pre-stub', 'f3727995') + ] + } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // A lock hash we cannot place is not evidence the command is stuck. + it('keeps a skill whose lock names no revision we know', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-pre-stub')], + new Map([['orca-cli', 'not-a-known-tree']]), + { 'orca-cli': [revision('digest-pre-stub', 'f3727995')] } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // Why: `diskTreeShas` silently drops digests that match no known revision, so a + // stale copy sitting beside an unidentifiable one must NOT gate the name — the + // unknown half could be anything, including a copy the command would converge. + it('keeps a skill when one placement is stale but another is unidentifiable', () => { + const result = convergableSkillNames( + [placement('orca-cli', 'digest-pre-stub'), placement('orca-cli', 'digest-unknown')], + new Map([['orca-cli', '091d9bcc']]), + { + 'orca-cli': [revision('digest-pre-stub', 'f3727995'), revision('digest-stub', '091d9bcc')] + } + ) + expect([...result]).toEqual(['orca-cli']) + }) + + // Why: copies the command never writes must not defeat the gate. An + // unidentifiable plugin-cache repack would otherwise read as an unresolved + // placement and re-arm the unwinnable update on the drifted canonical. + it('ignores an unidentifiable plugin-cache copy when judging the canonical', () => { + const result = convergableSkillNames( + [ + placement('orca-linear', 'digest-pre-stub'), + placement('orca-linear', 'digest-cache-repack', 'plugin-cache') + ], + new Map([['orca-linear', '091d9bcc']]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ] + } + ) + expect([...result]).toEqual([]) + }) + + // A cache copy parked at the lock's own revision is not an anchor either — + // the command only writes the canonical, which is still drifted. + it('ignores a plugin-cache copy that matches the lock', () => { + const result = convergableSkillNames( + [ + placement('orca-linear', 'digest-pre-stub'), + placement('orca-linear', 'digest-stub', 'plugin-cache') + ], + new Map([['orca-linear', '091d9bcc']]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ] + } + ) + expect([...result]).toEqual([]) + }) + + it('judges each locked skill independently', () => { + const result = convergableSkillNames( + [placement('orca-linear', 'digest-pre-stub'), placement('orca-cli', 'digest-installed')], + new Map([ + ['orca-linear', '091d9bcc'], + ['orca-cli', 'aaaa1111'] + ]), + { + 'orca-linear': [ + revision('digest-pre-stub', 'f3727995'), + revision('digest-stub', '091d9bcc') + ], + 'orca-cli': [revision('digest-installed', 'aaaa1111')] + } + ) + expect([...result]).toEqual(['orca-cli']) + }) +}) diff --git a/src/main/skills/skill-update-convergence.ts b/src/main/skills/skill-update-convergence.ts new file mode 100644 index 00000000000..41889058f3f --- /dev/null +++ b/src/main/skills/skill-update-convergence.ts @@ -0,0 +1,73 @@ +import { + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, + type SkillFreshnessInstallation, + type SkillKnownSnapshot +} from '../../shared/skill-freshness' + +/** + * Names `skills update` can still move, judged against what it believes it installed. + * + * The updater decides what to do by comparing its lock's `skillFolderHash` against + * the source tree; it never reads disk. So once the lock records a revision the + * filesystem does not actually have, the command reports "up to date", exits 0 and + * writes nothing — no retry converges it. Offering an update there promises work the + * command cannot do, which is exactly what `eligibleSkillUpdateNames` exists to avoid. + * + * The honest signal is the lock's hash versus the `gitTreeSha` of the revision the + * DISK hashes to. Deliberately not versus the bundled manifest: the updater pulls from + * the source repo, which legitimately runs ahead of what this build ships, and gating + * on the bundle would withhold real updates. When lock and disk agree, an update is + * still genuinely available and stays on offer. + * + * Unknown either way (no lock entry, or disk content matching no known revision) is + * left eligible — silence is not evidence the command is stuck. + */ +export function convergableSkillNames( + installations: readonly SkillFreshnessInstallation[], + globalSkillLocks: ReadonlyMap, + knownSnapshots: Readonly> +): ReadonlySet { + const convergable = new Set(globalSkillLocks.keys()) + for (const [name, lockHash] of globalSkillLocks) { + // Why: judged only over the placements the command writes, like eligibility + // itself. A plugin-cache or repo copy is never the command's to converge, so + // it must neither gate the name nor rescue it — an unidentifiable cache copy + // (or one parked at the lock's own revision) would otherwise defeat the gate + // and re-arm the unwinnable update. + const digests = installations + .filter( + (entry) => + entry.name === name && + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && + entry.observedPackageDigest + ) + .map((entry) => entry.observedPackageDigest) + if (digests.length === 0) { + continue + } + const revisions = knownSnapshots[name] ?? [] + const diskTreeShas = digests + .map((digest) => revisions.find((revision) => revision.packageDigest === digest)?.gitTreeSha) + .filter((sha): sha is string => Boolean(sha)) + // Why: only claim the lock is stale when BOTH sides are positively identified — + // the lock names a revision we know, and every placement resolves to a different + // known revision. A lock hash we cannot place (a source we do not bundle, a + // revision older than the registry) is not evidence of anything, and gating on it + // would withhold updates that would have worked. + const lockNamesAKnownRevision = revisions.some((entry) => entry.gitTreeSha === lockHash) + // `diskTreeShas` drops digests that match no known revision, so requiring every + // observed digest to resolve is what keeps `every` honest: without it, one stale + // copy beside one unidentifiable copy would gate the name off the resolved half + // alone, contradicting the unknown-stays-eligible rule above. + const everyPlacementResolved = diskTreeShas.length === digests.length + if ( + lockNamesAKnownRevision && + everyPlacementResolved && + diskTreeShas.length > 0 && + diskTreeShas.every((sha) => sha !== lockHash) + ) { + convergable.delete(name) + } + } + return convergable +} diff --git a/src/main/skills/skill-update-outcome.test.ts b/src/main/skills/skill-update-outcome.test.ts index 9f7d809daa6..4aa2ce401b2 100644 --- a/src/main/skills/skill-update-outcome.test.ts +++ b/src/main/skills/skill-update-outcome.test.ts @@ -38,13 +38,10 @@ describe('skillUpdateFailedNames', () => { expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'current')])).toEqual([]) }) - // Reversed deliberately. `skills update` compares its lock against the source - // and never reads disk, so once the lock has advanced past the installed bytes - // it prints "up to date", exits 0 and writes nothing — leaving a recognised - // older revision that no retry converges. Blaming the run for that invented a - // failure the CLI never reported and armed a Retry that could not succeed. - it('does not blame the run for a copy the update command cannot converge', () => { - expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([]) + it('reports a copy the run left outdated', () => { + expect(skillUpdateFailedNames(['orca-cli'], [placement('orca-cli', 'outdated')])).toEqual([ + 'orca-cli' + ]) }) it('reports a half-written bundle instead of reading it as success', () => { @@ -79,31 +76,20 @@ describe('skillUpdateFailedNames', () => { ).toEqual([]) }) - // Still fails on a DEGRADED alias — only `outdated` was reclassified, so a - // half-written alias beside a good canonical copy must not read as success. - it('fails the name when any convergent alias was left broken', () => { - expect( - skillUpdateFailedNames( - ['orca-cli'], - [placement('orca-cli', 'current'), placement('orca-cli', 'unrecognized', 'provider-alias')] - ) - ).toEqual(['orca-cli']) - }) - - it('does not fail the name for an alias the command merely left old', () => { + it('fails the name when any convergent alias was left behind', () => { expect( skillUpdateFailedNames( ['orca-cli'], [placement('orca-cli', 'current'), placement('orca-cli', 'outdated', 'provider-alias')] ) - ).toEqual([]) + ).toEqual(['orca-cli']) }) it('judges each requested name independently', () => { expect( skillUpdateFailedNames( ['orca-cli', 'orchestration'], - [placement('orca-cli', 'current'), placement('orchestration', 'unrecognized')] + [placement('orca-cli', 'current'), placement('orchestration', 'outdated')] ) ).toEqual(['orchestration']) }) diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts index 77a005446a8..6d75d4b5efc 100644 --- a/src/main/skills/skill-update-outcome.ts +++ b/src/main/skills/skill-update-outcome.ts @@ -34,22 +34,6 @@ export function skillUpdateFailedNames( } // `newer-known` counts as landed: the CLI pulls from the source repo, which // can be ahead of the revision this build ships in its manifest. - // - // `outdated` is not a failed run either. `skills update` compares its lock's - // recorded hash against the source and never reads disk, so when the lock has - // advanced past the installed bytes it reports "up to date", exits 0, and - // writes nothing. The copy is left intact — a recognised older revision, not - // a broken one — and no amount of retrying converges it. Calling that an - // error invented a failure the CLI never reported and armed a Retry that - // provably could not succeed. The freshness badge still marks the copy - // not-current, so this only stops the run being blamed for it. - // - // A genuinely botched write is still caught: a half-written bundle hashes to - // `unrecognized`, a wholly-degraded or removed copy leaves no convergent - // placement, and process-level failure surfaces through the spawn error. - return convergent.some( - (entry) => - entry.status !== 'current' && entry.status !== 'newer-known' && entry.status !== 'outdated' - ) + return convergent.some((entry) => entry.status !== 'current' && entry.status !== 'newer-known') }) } diff --git a/src/main/skills/skill-update-registration.ts b/src/main/skills/skill-update-registration.ts index 658679dacd4..66231d71fbe 100644 --- a/src/main/skills/skill-update-registration.ts +++ b/src/main/skills/skill-update-registration.ts @@ -24,6 +24,20 @@ function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string { export async function readGloballyUpdatableSkillNames( args: SkillUpdateRegistrationArgs = {} ): Promise> { + return new Set((await readGloballyUpdatableSkillLocks(args)).keys()) +} + +/** + * Each updatable skill's recorded `skillFolderHash`, keyed by name. + * + * The hash is what the updater believes it installed. It is deliberately + * exposed alongside the names because `skills update` decides what to do by + * comparing this against the source and never reads disk — so when it disagrees + * with the bytes actually on disk, the command can only no-op. + */ +export async function readGloballyUpdatableSkillLocks( + args: SkillUpdateRegistrationArgs = {} +): Promise> { try { const parsed = JSON.parse(await readFile(globalSkillLockPath(args), 'utf8')) as { version?: unknown @@ -36,10 +50,10 @@ export async function readGloballyUpdatableSkillNames( typeof parsed.skills !== 'object' || Array.isArray(parsed.skills) ) { - return new Set() + return new Map() } - return new Set( + return new Map( Object.entries(parsed.skills) .filter(([, value]) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -59,9 +73,11 @@ export async function readGloballyUpdatableSkillNames( entry.source.length > 0 ) }) - .map(([name]) => name) + .map( + ([name, value]) => [name, (value as { skillFolderHash: string }).skillFolderHash] as const + ) ) } catch { - return new Set() + return new Map() } } From e551d3ec0d1bb1bf72173204b615eae807ffb849 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:21:13 -0700 Subject: [PATCH 19/53] perf(lint): consolidate code-quality gates into Oxlint (#11117) Consolidate standalone code-quality scanners into Oxlint, preserve focused native/type-aware enforcement, add custom plugin coverage, and harden deferred PTY test cleanup. --- .github/workflows/pr.yml | 14 +- .oxlintrc.json | 30 ++ ...> oxlint-code-quality-native-plugins.json} | 14 +- config/oxlint-code-quality-type-aware.json | 6 +- .../quadratic-buffer-concat.mjs | 266 ++++++++++++++ .../renderer-scrollbar-style.mjs | 292 +++++++++++++++ config/oxlint-switch-exhaustiveness.json | 19 - .../app-store-performance-plugin.test.mjs | 41 +-- config/scripts/check-changed-code-quality.mjs | 7 +- .../scripts/check-quadratic-buffer-concat.mjs | 343 ------------------ .../check-quadratic-buffer-concat.test.mjs | 146 -------- config/scripts/check-styled-scrollbars.mjs | 87 ----- .../scripts/check-styled-scrollbars.test.mjs | 199 ---------- config/scripts/oxlint-plugin-test-runner.mjs | 58 +++ .../quadratic-buffer-concat-plugin.test.mjs | 113 ++++++ .../renderer-scrollbar-style-plugin.test.mjs | 116 ++++++ .../styled-scrollbar-jsx-check.mjs | 312 ---------------- package.json | 7 +- .../terminal-pane/pty-connection.test.ts | 18 +- 19 files changed, 915 insertions(+), 1173 deletions(-) rename config/{oxlint-code-quality.json => oxlint-code-quality-native-plugins.json} (71%) create mode 100644 config/oxlint-plugins/quadratic-buffer-concat.mjs create mode 100644 config/oxlint-plugins/renderer-scrollbar-style.mjs delete mode 100644 config/oxlint-switch-exhaustiveness.json delete mode 100644 config/scripts/check-quadratic-buffer-concat.mjs delete mode 100644 config/scripts/check-quadratic-buffer-concat.test.mjs delete mode 100644 config/scripts/check-styled-scrollbars.mjs delete mode 100644 config/scripts/check-styled-scrollbars.test.mjs create mode 100644 config/scripts/oxlint-plugin-test-runner.mjs create mode 100644 config/scripts/quadratic-buffer-concat-plugin.test.mjs create mode 100644 config/scripts/renderer-scrollbar-style-plugin.test.mjs delete mode 100644 config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f51e54b9620..aa65296d36d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -32,11 +32,11 @@ jobs: - name: Lint run: pnpm exec oxlint --format github - - name: Enforce full code-quality baseline - run: pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware + - name: Enforce focused code-quality plugins + run: pnpm run audit:code-quality:native - - name: Check switch exhaustiveness - run: pnpm run lint:switch-exhaustiveness + - name: Enforce type-aware code-quality baseline + run: pnpm run audit:code-quality:type-aware - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -47,12 +47,6 @@ jobs: - name: Check Zustand selector fan-out budget run: pnpm run check:zustand-selector-fanout - - name: Check styled scrollbars - run: pnpm check:styled-scrollbars - - - name: Check quadratic buffer concatenation - run: pnpm run check:quadratic-buffer-concat - - name: Check reliability gate manifest run: pnpm run check:reliability-gates diff --git a/.oxlintrc.json b/.oxlintrc.json index 5eb541a89c1..9c64c75323f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,12 +5,29 @@ { "name": "mobile-pairing", "specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs" + }, + { + "name": "app-store-performance", + "specifier": "./config/oxlint-plugins/app-store-performance.mjs" + }, + { + "name": "quadratic-buffer-concat", + "specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs" + }, + { + "name": "renderer-scrollbar-style", + "specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs" } ], "categories": { "correctness": "error" }, "rules": { + "app-store-performance/require-selector": "error", + "app-store-performance/no-identity-selector": "error", + "app-store-performance/no-fresh-selector-result": "error", + "no-fallthrough": "error", + "quadratic-buffer-concat/no-loop-carried-concat": "error", "react/jsx-no-duplicate-props": "error", "react/jsx-no-undef": "error", "react/no-children-prop": "error", @@ -93,6 +110,19 @@ "unicorn/throw-new-error": "error" }, "overrides": [ + { + "files": ["src/renderer/src/**/*.{ts,tsx}"], + "rules": { + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error" + } + }, + { + "files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"], + "rules": { + "quadratic-buffer-concat/no-loop-carried-concat": "off", + "renderer-scrollbar-style/require-styled-vertical-scrollbar": "off" + } + }, { "files": ["**/*.ts"], "rules": { diff --git a/config/oxlint-code-quality.json b/config/oxlint-code-quality-native-plugins.json similarity index 71% rename from config/oxlint-code-quality.json rename to config/oxlint-code-quality-native-plugins.json index bfa1c115c45..5d9cd118e74 100644 --- a/config/oxlint-code-quality.json +++ b/config/oxlint-code-quality-native-plugins.json @@ -1,6 +1,6 @@ { "$schema": "../node_modules/oxlint/configuration_schema.json", - "plugins": ["import", "jsx-a11y", "promise", "react", "react-hooks", "typescript", "vitest"], + "plugins": ["import", "jsx-a11y", "react-hooks", "vitest"], "categories": { "correctness": "off", "suspicious": "off", @@ -10,23 +10,13 @@ "restriction": "off", "nursery": "off" }, - "jsPlugins": [ - { - "name": "app-store-performance", - "specifier": "./oxlint-plugins/app-store-performance.mjs" - } - ], "rules": { - "app-store-performance/require-selector": "warn", - "app-store-performance/no-identity-selector": "warn", - "app-store-performance/no-fresh-selector-result": "warn", "import/export": "warn", "import/named": "warn", "import/namespace": "warn", "import/no-cycle": ["warn", { "maxDepth": 3 }], "import/no-duplicates": "warn", - "import/no-self-import": "warn", - "no-fallthrough": "warn" + "import/no-self-import": "warn" }, "overrides": [ { diff --git a/config/oxlint-code-quality-type-aware.json b/config/oxlint-code-quality-type-aware.json index 9198df65775..4c0e01fa748 100644 --- a/config/oxlint-code-quality-type-aware.json +++ b/config/oxlint-code-quality-type-aware.json @@ -13,7 +13,11 @@ "rules": { "typescript/await-thenable": "warn", "typescript/restrict-plus-operands": "warn", - "typescript/restrict-template-expressions": "warn" + "typescript/restrict-template-expressions": "warn", + "typescript/switch-exhaustiveness-check": [ + "error", + { "allowDefaultCaseForExhaustiveSwitch": false } + ] }, "overrides": [ { diff --git a/config/oxlint-plugins/quadratic-buffer-concat.mjs b/config/oxlint-plugins/quadratic-buffer-concat.mjs new file mode 100644 index 00000000000..b2399345f5b --- /dev/null +++ b/config/oxlint-plugins/quadratic-buffer-concat.mjs @@ -0,0 +1,266 @@ +const LOOP_TYPES = new Set([ + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'WhileStatement', + 'DoWhileStatement' +]) +const ASSIGNMENT_OPERATORS = new Set(['=', '+=', '??=', '||=', '&&=']) +const EXPRESSION_WRAPPERS = new Set([ + 'ChainExpression', + 'TSAsExpression', + 'TSNonNullExpression', + 'TSSatisfiesExpression', + 'TypeCastExpression' +]) + +function normalizeReferenceText(text) { + return text.replaceAll(/\s+/g, '') +} + +function sourceText(context, node) { + return context.sourceCode.getText(node) +} + +function memberPropertyName(node) { + if (node?.type !== 'MemberExpression') { + return null + } + if (!node.computed && node.property.type === 'Identifier') { + return node.property.name + } + return node.property.type === 'Literal' && typeof node.property.value === 'string' + ? node.property.value + : null +} + +function rootReferenceText(context, node) { + if (node?.type === 'Identifier') { + return node.name + } + if (node?.type === 'MemberExpression') { + return node.object.type === 'ThisExpression' + ? normalizeReferenceText(sourceText(context, node)) + : rootReferenceText(context, node.object) + } + if (node?.type === 'CallExpression') { + return rootReferenceText(context, node.callee) + } + if (EXPRESSION_WRAPPERS.has(node?.type)) { + return rootReferenceText(context, node.expression) + } + return null +} + +function isBufferConcatCall(node) { + return ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Buffer' && + memberPropertyName(node.callee) === 'concat' && + node.arguments[0]?.type === 'ArrayExpression' + ) +} + +function enclosingLoop(node) { + for (let current = node.parent; current; current = current.parent) { + if (LOOP_TYPES.has(current.type)) { + return current + } + } + return null +} + +function nodeStart(node) { + return node.start ?? node.range?.[0] ?? 0 +} + +function nodeEnd(node) { + return node.end ?? node.range?.[1] ?? 0 +} + +function isDeclaredInsideLoop(declarationStart, loop) { + if (declarationStart < nodeStart(loop) || declarationStart >= nodeEnd(loop)) { + return false + } + if (loop.type !== 'ForStatement' || !loop.init) { + return true + } + return declarationStart < nodeStart(loop.init) || declarationStart >= nodeEnd(loop.init) +} + +function collectBindingNames(pattern, names) { + if (!pattern) { + return + } + if (pattern.type === 'Identifier') { + names.push(pattern.name) + } else if (pattern.type === 'RestElement') { + collectBindingNames(pattern.argument, names) + } else if (pattern.type === 'AssignmentPattern') { + collectBindingNames(pattern.left, names) + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + collectBindingNames( + property.type === 'RestElement' ? property.argument : property.value, + names + ) + } + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + collectBindingNames(element, names) + } + } +} + +function visitChildren(node, visit) { + for (const [key, child] of Object.entries(node)) { + if (['parent', 'loc', 'range'].includes(key)) { + continue + } + if (Array.isArray(child)) { + for (const item of child) { + if (item?.type) { + visit(item) + } + } + } else if (child?.type) { + visit(child) + } + } +} + +function collectAssignedRoots(context, loop) { + const assigned = new Set() + const visit = (node) => { + if (node.type === 'AssignmentExpression' && ASSIGNMENT_OPERATORS.has(node.operator)) { + const root = rootReferenceText(context, node.left) + if (root) { + assigned.add(root) + } + } + visitChildren(node, visit) + } + visit(loop.body) + return assigned +} + +function assignmentTargetOf(context, call) { + let node = call + let parent = node.parent + while ( + parent && + (EXPRESSION_WRAPPERS.has(parent.type) || + (parent.type === 'ConditionalExpression' && parent.test !== node)) + ) { + node = parent + parent = parent.parent + } + if (parent?.type !== 'AssignmentExpression' || parent.operator !== '=' || parent.right !== node) { + return null + } + return { + text: normalizeReferenceText(sourceText(context, parent.left)), + root: rootReferenceText(context, parent.left) + } +} + +function concatOperands(context, call) { + return call.arguments[0].elements.filter(Boolean).map((element) => { + const spread = element.type === 'SpreadElement' + const expression = spread ? element.argument : element + return { + spread, + text: normalizeReferenceText(sourceText(context, expression)), + root: rootReferenceText(context, expression) + } + }) +} + +function isLoopCarried(root, loop, declarations) { + const starts = declarations.get(root) + return !starts || !starts.some((start) => isDeclaredInsideLoop(start, loop)) +} + +function quadraticAccumulator(context, call, loop, declarations, assignedRoots) { + const operands = concatOperands(context, call) + const target = assignmentTargetOf(context, call) + const selfOperand = target + ? operands.find((operand) => operand.text === target.text || operand.root === target.root) + : null + if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) { + return target.text + } + + for (const operand of operands) { + if ( + !operand.spread && + operand.root && + assignedRoots.has(operand.root) && + isLoopCarried(operand.root, loop, declarations) + ) { + return operand.root + } + } + return null +} + +function createRule(context) { + const declarations = new Map() + const assignedRootsByLoop = new WeakMap() + const recordBindings = (pattern, owner) => { + const names = [] + collectBindingNames(pattern, names) + for (const name of names) { + const starts = declarations.get(name) ?? [] + starts.push(nodeStart(owner)) + declarations.set(name, starts) + } + } + const recordParameters = (node) => { + for (const parameter of node.params) { + recordBindings(parameter, parameter) + } + } + + return { + VariableDeclarator(node) { + recordBindings(node.id, node) + }, + FunctionDeclaration: recordParameters, + FunctionExpression: recordParameters, + ArrowFunctionExpression: recordParameters, + CatchClause(node) { + recordBindings(node.param, node.param) + }, + CallExpression(node) { + if (!isBufferConcatCall(node)) { + return + } + const loop = enclosingLoop(node) + if (!loop) { + return + } + let assignedRoots = assignedRootsByLoop.get(loop) + if (!assignedRoots) { + assignedRoots = collectAssignedRoots(context, loop) + assignedRootsByLoop.set(loop, assignedRoots) + } + const accumulator = quadraticAccumulator(context, node, loop, declarations, assignedRoots) + if (accumulator) { + context.report({ + node, + message: `Buffer.concat rebuilds loop-carried ${accumulator}; collect chunks and concatenate once after the loop.` + }) + } + } + } +} + +export default { + meta: { name: 'quadratic-buffer-concat' }, + rules: { + 'no-loop-carried-concat': { create: createRule } + } +} diff --git a/config/oxlint-plugins/renderer-scrollbar-style.mjs b/config/oxlint-plugins/renderer-scrollbar-style.mjs new file mode 100644 index 00000000000..4938d66ef79 --- /dev/null +++ b/config/oxlint-plugins/renderer-scrollbar-style.mjs @@ -0,0 +1,292 @@ +const STYLED_SCROLLBAR_CLASSES = new Set([ + 'scrollbar-sleek', + 'scrollbar-editor', + 'worktree-sidebar-scrollbar' +]) +const VERTICAL_SCROLL_CLASSES = new Set([ + 'overflow-auto', + 'overflow-scroll', + 'overflow-y-auto', + 'overflow-y-scroll' +]) +const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll']) + +function withoutImportantModifier(className) { + const withoutPrefix = className.startsWith('!') ? className.slice(1) : className + return withoutPrefix.endsWith('!') ? withoutPrefix.slice(0, -1) : withoutPrefix +} + +export function plainClassName(token) { + const normalizedToken = token.startsWith('!') ? token.slice(1) : token + const parts = [] + let bracketDepth = 0 + let currentPart = '' + + for (const char of normalizedToken) { + if (char === '[') { + bracketDepth += 1 + } else if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1) + } + if (char === ':' && bracketDepth === 0) { + parts.push(currentPart) + currentPart = '' + } else { + currentPart += char + } + } + + parts.push(currentPart) + return withoutImportantModifier(parts.at(-1) ?? '') +} + +function classTokenParts(token) { + const variants = [] + let bracketDepth = 0 + let currentPart = '' + + for (const char of token.startsWith('!') ? token.slice(1) : token) { + if (char === '[') { + bracketDepth += 1 + } else if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1) + } + if (char === ':' && bracketDepth === 0) { + variants.push(currentPart) + currentPart = '' + } else { + currentPart += char + } + } + + return { className: withoutImportantModifier(currentPart), variants: variants.filter(Boolean) } +} + +function classTokens(text) { + return text.split(/\s+/).filter(Boolean).map(classTokenParts) +} + +function sameVariants(left, right) { + return left.length === right.length && left.every((variant, index) => variant === right[index]) +} + +function literalHasScrollbarForVertical(text, verticalToken) { + return classTokens(text).some( + (candidate) => + STYLED_SCROLLBAR_CLASSES.has(candidate.className) && + (candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants)) + ) +} + +function uncoveredVerticalClass(text) { + return classTokens(text).find( + (token) => + VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token) + ) +} + +function stringLiteralTexts(node) { + if (node?.type === 'Literal' && typeof node.value === 'string') { + return [node.value] + } + if (node?.type !== 'TemplateLiteral') { + return [] + } + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw) +} + +function visitChildren(node, visit) { + for (const [key, child] of Object.entries(node)) { + if (['parent', 'loc', 'range'].includes(key)) { + continue + } + if (Array.isArray(child)) { + for (const item of child) { + if (item?.type) { + visit(item) + } + } + } else if (child?.type) { + visit(child) + } + } +} + +function collectClassLiteralReports(node) { + const reports = [] + const visit = (current) => { + for (const text of stringLiteralTexts(current)) { + const uncovered = uncoveredVerticalClass(text) + if (uncovered) { + reports.push({ node: current, detail: uncovered.className }) + } + } + visitChildren(current, visit) + } + visit(node) + return reports +} + +function expressionHasStyledScrollbarLiteral(node) { + let found = false + const visit = (current) => { + if (found || current.type === 'ConditionalExpression' || current.type === 'LogicalExpression') { + return + } + found = stringLiteralTexts(current).some((text) => + classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className)) + ) + if (!found) { + visitChildren(current, visit) + } + } + visit(node) + return found +} + +function propertyName(node) { + if (node?.type !== 'Property') { + return null + } + if (!node.computed && node.key.type === 'Identifier') { + return node.key.name + } + return node.key.type === 'Literal' && typeof node.key.value === 'string' ? node.key.value : null +} + +function styleValueIsVerticalScroll(name, value) { + const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean) + if (parts.length === 0) { + return false + } + if (name === 'overflowY' || name === 'overflow-y') { + return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0]) + } + if (name !== 'overflow') { + return false + } + return VERTICAL_SCROLL_STYLE_VALUES.has(parts.length > 1 ? parts[1] : parts[0]) +} + +function collectStyleReports(node) { + const reports = [] + const visit = (current) => { + if (current.type === 'Property') { + const name = propertyName(current) + for (const value of name ? stringLiteralTexts(current.value) : []) { + if (styleValueIsVerticalScroll(name, value)) { + reports.push({ node: current, detail: 'inline vertical scroll' }) + } + } + visit(current.value) + return + } + visitChildren(current, visit) + } + visit(node) + return reports +} + +function unwrapExpression(node) { + if ( + ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ChainExpression'].includes( + node?.type + ) + ) { + return unwrapExpression(node.expression) + } + return node +} + +function spreadPropExpressions(expression, propName) { + const node = unwrapExpression(expression) + if (!node) { + return [] + } + if (node.type === 'ConditionalExpression') { + return [ + ...spreadPropExpressions(node.consequent, propName), + ...spreadPropExpressions(node.alternate, propName) + ] + } + if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') { + return [ + ...spreadPropExpressions(node.left, propName), + ...spreadPropExpressions(node.right, propName) + ] + } + if (node.type !== 'ObjectExpression') { + return [] + } + return node.properties.flatMap((property) => { + if (property.type === 'SpreadElement') { + return spreadPropExpressions(property.argument, propName) + } + return propertyName(property) === propName ? [property.value] : [] + }) +} + +function jsxAttributeExpression(attribute) { + if (attribute.value?.type === 'Literal') { + return attribute.value + } + return attribute.value?.type === 'JSXExpressionContainer' ? attribute.value.expression : null +} + +function jsxElementReports(node) { + let classExpression = null + const styleExpressions = [] + + for (const attribute of node.attributes) { + if (attribute.type === 'JSXSpreadAttribute') { + const spreadClassExpression = spreadPropExpressions(attribute.argument, 'className').at(-1) + if (spreadClassExpression) { + classExpression = spreadClassExpression + } + styleExpressions.push(...spreadPropExpressions(attribute.argument, 'style')) + } else if (attribute.name?.name === 'className') { + classExpression = jsxAttributeExpression(attribute) + } else if (attribute.name?.name === 'style') { + const expression = jsxAttributeExpression(attribute) + if (expression) { + styleExpressions.push(expression) + } + } + } + + const reports = classExpression ? collectClassLiteralReports(classExpression) : [] + if (!classExpression || !expressionHasStyledScrollbarLiteral(classExpression)) { + for (const expression of styleExpressions) { + reports.push(...collectStyleReports(expression)) + } + } + return reports +} + +function bindContext(createVisitors) { + return (context) => { + const visitors = createVisitors() + for (const [nodeType, visit] of Object.entries(visitors)) { + visitors[nodeType] = visit.bind(context) + } + return visitors + } +} + +export default { + meta: { name: 'renderer-scrollbar-style' }, + rules: { + 'require-styled-vertical-scrollbar': { + create: bindContext(() => ({ + JSXOpeningElement(node) { + for (const report of jsxElementReports(node)) { + this.report({ + node: report.node, + message: `Vertical scroll container (${report.detail}) must use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.` + }) + } + } + })) + } + } +} diff --git a/config/oxlint-switch-exhaustiveness.json b/config/oxlint-switch-exhaustiveness.json deleted file mode 100644 index c967a7b9b5d..00000000000 --- a/config/oxlint-switch-exhaustiveness.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "../node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript"], - "categories": { - "correctness": "off", - "suspicious": "off", - "pedantic": "off", - "perf": "off", - "style": "off", - "restriction": "off", - "nursery": "off" - }, - "rules": { - "typescript/switch-exhaustiveness-check": [ - "error", - { "allowDefaultCaseForExhaustiveSwitch": false } - ] - } -} diff --git a/config/scripts/app-store-performance-plugin.test.mjs b/config/scripts/app-store-performance-plugin.test.mjs index 344918309f8..bb2f305ba92 100644 --- a/config/scripts/app-store-performance-plugin.test.mjs +++ b/config/scripts/app-store-performance-plugin.test.mjs @@ -1,41 +1,20 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' const pluginPath = path.resolve('config/oxlint-plugins/app-store-performance.mjs') -const oxlintPath = path.resolve( - process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint' -) function lintSource(source) { - const directory = mkdtempSync(path.join(tmpdir(), 'orca-app-store-lint-')) - const sourcePath = path.join(directory, 'sample.tsx') - const configPath = path.join(directory, 'oxlint.json') - writeFileSync(sourcePath, source) - writeFileSync( - configPath, - JSON.stringify({ - categories: { - correctness: 'off' - }, - jsPlugins: [{ name: 'app-store-performance', specifier: pluginPath }], - rules: { - 'app-store-performance/require-selector': 'warn', - 'app-store-performance/no-identity-selector': 'warn', - 'app-store-performance/no-fresh-selector-result': 'warn' - } - }) - ) - const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], { - encoding: 'utf8' + return runOxlintPluginOnSource({ + pluginName: 'app-store-performance', + pluginPath, + source, + rules: { + 'app-store-performance/require-selector': 'warn', + 'app-store-performance/no-identity-selector': 'warn', + 'app-store-performance/no-fresh-selector-result': 'warn' + } }) - if (result.error) { - throw result.error - } - expect(result.status).toBe(0) - return JSON.parse(result.stdout).diagnostics } describe('app store performance Oxlint plugin', () => { diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 7efcbe480f1..a8fd5b73926 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -9,12 +9,7 @@ const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ const OXLINT_SCANS = [ { label: 'code quality', - args: [ - '--config', - 'config/oxlint-code-quality.json', - '--report-unused-disable-directives-severity', - 'warn' - ] + args: ['--config', '.oxlintrc.json', '--report-unused-disable-directives-severity', 'warn'] }, { label: 'type-aware code quality', diff --git a/config/scripts/check-quadratic-buffer-concat.mjs b/config/scripts/check-quadratic-buffer-concat.mjs deleted file mode 100644 index 611157c3eaa..00000000000 --- a/config/scripts/check-quadratic-buffer-concat.mjs +++ /dev/null @@ -1,343 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import process from 'node:process' - -// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. -import ts from 'typescript-api' - -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts']) -const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) -const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile'] - -const LOOP_KINDS = new Set([ - ts.SyntaxKind.ForStatement, - ts.SyntaxKind.ForInStatement, - ts.SyntaxKind.ForOfStatement, - ts.SyntaxKind.WhileStatement, - ts.SyntaxKind.DoStatement -]) - -const ASSIGNMENT_OPERATORS = new Set([ - ts.SyntaxKind.EqualsToken, - ts.SyntaxKind.PlusEqualsToken, - ts.SyntaxKind.QuestionQuestionEqualsToken, - ts.SyntaxKind.BarBarEqualsToken, - ts.SyntaxKind.AmpersandAmpersandEqualsToken -]) - -function normalizeReferenceText(text) { - return text.replaceAll(/\s+/g, '') -} - -// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)` -// still resolves to `carry`. `this.x` stops at the property: the class field is -// the accumulator. -function rootReferenceText(node) { - if (ts.isIdentifier(node)) { - return node.text - } - if (ts.isPropertyAccessExpression(node)) { - return node.expression.kind === ts.SyntaxKind.ThisKeyword - ? normalizeReferenceText(node.getText()) - : rootReferenceText(node.expression) - } - if ( - ts.isElementAccessExpression(node) || - ts.isCallExpression(node) || - ts.isNonNullExpression(node) || - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) - ) { - return rootReferenceText(node.expression) - } - return undefined -} - -function isBufferConcatCall(node) { - return ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === 'concat' && - ts.isIdentifier(node.expression.expression) && - node.expression.expression.text === 'Buffer' && - node.arguments.length > 0 && - ts.isArrayLiteralExpression(node.arguments[0]) - ) -} - -function enclosingLoop(node) { - for (let current = node.parent; current; current = current.parent) { - if (LOOP_KINDS.has(current.kind)) { - return current - } - } - return undefined -} - -// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside -// the loop node, so the initializer does not count as loop-local. -function isDeclaredInsideLoop(declarationStart, loop) { - if (declarationStart < loop.getStart() || declarationStart >= loop.end) { - return false - } - const initializer = ts.isForStatement(loop) ? loop.initializer : undefined - if (!initializer) { - return true - } - return declarationStart < initializer.getStart() || declarationStart >= initializer.end -} - -function collectBindingNames(name, into) { - if (ts.isIdentifier(name)) { - into.push(name) - return - } - if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { - for (const element of name.elements) { - if (ts.isBindingElement(element)) { - collectBindingNames(element.name, into) - } - } - } -} - -function collectDeclarations(sourceFile) { - const declarations = new Map() - const record = (identifier, node) => { - const existing = declarations.get(identifier.text) - const start = node.getStart(sourceFile) - if (existing) { - existing.push(start) - } else { - declarations.set(identifier.text, [start]) - } - } - - const visit = (node) => { - if (ts.isVariableDeclaration(node) || ts.isParameter(node)) { - const names = [] - collectBindingNames(node.name, names) - for (const identifier of names) { - record(identifier, node) - } - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return declarations -} - -function collectAssignedRoots(loop) { - const assigned = new Set() - const visit = (node) => { - if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) { - const root = rootReferenceText(node.left) - if (root) { - assigned.add(root) - } - } - ts.forEachChild(node, visit) - } - visit(loop.statement) - return assigned -} - -// Unwrap the wrappers a concat result passes through before it lands on the -// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts. -function assignmentTargetOf(call) { - let node = call - let parent = node.parent - while ( - parent && - (ts.isParenthesizedExpression(parent) || - ts.isAsExpression(parent) || - ts.isNonNullExpression(parent) || - (ts.isConditionalExpression(parent) && parent.condition !== node)) - ) { - node = parent - parent = parent.parent - } - if ( - parent && - ts.isBinaryExpression(parent) && - parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && - parent.right === node - ) { - return { - text: normalizeReferenceText(parent.left.getText()), - root: rootReferenceText(parent.left) - } - } - return undefined -} - -function concatOperands(call) { - return call.arguments[0].elements.map((element) => { - const spread = ts.isSpreadElement(element) - const expression = spread ? element.expression : element - return { - spread, - text: normalizeReferenceText(expression.getText()), - root: rootReferenceText(expression) - } - }) -} - -// A binding reset on every iteration cannot accumulate; only one that outlives -// the iteration carries the cost forward. -function isLoopCarried(root, loop, declarations) { - const starts = declarations.get(root) - if (!starts) { - return true - } - return !starts.some((start) => isDeclaredInsideLoop(start, loop)) -} - -function quadraticAccumulator(call, loop, declarations, assignedRoots) { - const operands = concatOperands(call) - const target = assignmentTargetOf(call) - // The result feeds straight back into its own input: every iteration re-copies - // everything accumulated so far. - const selfOperand = target - ? operands.find((operand) => operand.text === target.text || operand.root === target.root) - : undefined - if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) { - return target.text - } - - // No direct self-assignment, so look for a loop-carried operand reassigned - // inside the loop: the concat result reaches it by some other path. Spread - // operands are the sanctioned chunk-list fix, not this bug, so skip them. - for (const operand of operands) { - if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) { - continue - } - if (isLoopCarried(operand.root, loop, declarations)) { - return operand.root - } - } - return undefined -} - -export function reportQuadraticBufferConcat(filePath, sourceText) { - if (!sourceText.includes('Buffer.concat')) { - return [] - } - const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true) - const declarations = collectDeclarations(sourceFile) - const assignedRootsByLoop = new Map() - const reports = [] - const seen = new Set() - - const visit = (node) => { - if (isBufferConcatCall(node)) { - const loop = enclosingLoop(node) - if (loop) { - let assignedRoots = assignedRootsByLoop.get(loop) - if (!assignedRoots) { - assignedRoots = collectAssignedRoots(loop) - assignedRootsByLoop.set(loop, assignedRoots) - } - const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots) - if (accumulator) { - const start = node.getStart(sourceFile) - if (!seen.has(start)) { - seen.add(start) - const { line, character } = sourceFile.getLineAndCharacterOfPosition(start) - reports.push({ - filePath, - line: line + 1, - column: character + 1, - accumulator, - text: node.getText() - }) - } - } - } - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return reports -} - -export function normalizePath(root, filePath) { - return path.relative(root, filePath).split(path.sep).join('/') -} - -function isSkippedFile(root, filePath) { - const relative = normalizePath(root, filePath) - // Benchmarks keep the pre-fix shape on purpose so they can measure against it. - if ( - relative.includes('.test.') || - relative.includes('.spec.') || - relative.includes('-benchmark.') - ) { - return true - } - return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) -} - -async function collectSourceFiles(root, dir) { - let entries - try { - entries = await fs.readdir(dir, { withFileTypes: true }) - } catch { - return [] - } - const files = [] - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (!SKIP_PATH_PARTS.has(entry.name)) { - files.push(...(await collectSourceFiles(root, fullPath))) - } - } else if ( - entry.isFile() && - SOURCE_EXTENSIONS.has(path.extname(entry.name)) && - !isSkippedFile(root, fullPath) - ) { - files.push(fullPath) - } - } - - return files -} - -function formatReports(root, reports) { - return reports - .map( - (report) => - `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}` - ) - .join('\n') -} - -export async function main(root = process.cwd()) { - const reports = [] - for (const scanRoot of SCAN_ROOTS) { - const files = await collectSourceFiles(root, path.join(root, scanRoot)) - for (const filePath of files) { - const sourceText = await fs.readFile(filePath, 'utf8') - reports.push(...reportQuadraticBufferConcat(filePath, sourceText)) - } - } - if (reports.length === 0) { - return 0 - } - - console.error('Buffer.concat must not rebuild a loop-carried accumulator.') - console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).') - console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.') - console.error('') - console.error(formatReports(root, reports)) - return 1 -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(await main()) -} diff --git a/config/scripts/check-quadratic-buffer-concat.test.mjs b/config/scripts/check-quadratic-buffer-concat.test.mjs deleted file mode 100644 index bc17ce8c7d3..00000000000 --- a/config/scripts/check-quadratic-buffer-concat.test.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs' - -describe('check-quadratic-buffer-concat', () => { - it('reports an accumulator rebuilt from itself in a for-of loop', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }' - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('acc') - }) - - it('reports the accumulator when it trails the new chunk', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('reports a spread of the accumulator into its own concat', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('reports the real pre-fix ai-vault stream carry', () => { - const reports = reportQuadraticBufferConcat( - 'session-scanner-parse-cache.ts', - `async function read(stream) { - let remainder = null - for await (const chunk of stream) { - const data = remainder ? Buffer.concat([remainder, chunk]) : chunk - remainder = Buffer.from(data.subarray(lineStart)) - } - }` - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('remainder') - }) - - it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => { - const reports = reportQuadraticBufferConcat( - 'agent-hook-listener.ts', - `function read(fd, size) { - let carryBytes = Buffer.alloc(0) - while (bytesRead < size) { - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - carryBytes = combined.subarray(0, firstNewline) - } - }` - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('carryBytes') - }) - - it('accepts the chunk-list fix that joins once after the loop', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)' - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts spreading a carry chunk list, which is the sanctioned fix', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - `let carryChunks = [] - while (scanEnd > 0) { - const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) - carryChunks = [buffer.subarray(0, firstNewline)] - }` - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts a concat whose result never outlives the iteration', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }' - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }' - ) - - expect(reports).toHaveLength(0) - }) - - it('reports an accumulator declared in a classic for initializer', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('reports a class field accumulator rebuilt inside a loop', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }' - ) - - expect(reports).toHaveLength(1) - expect(reports[0].accumulator).toBe('this.pending') - }) - - it('reports an accumulator guarded by an emptiness ternary', () => { - const reports = reportQuadraticBufferConcat( - 'Example.ts', - 'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }' - ) - - expect(reports).toHaveLength(1) - }) - - it('ignores a per-event accumulator with no enclosing loop', () => { - const reports = reportQuadraticBufferConcat( - 'scrcpy-stream-session.ts', - 'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }' - ) - - expect(reports).toHaveLength(0) - }) - - it('ignores files with no Buffer.concat at all', () => { - expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0) - }) -}) diff --git a/config/scripts/check-styled-scrollbars.mjs b/config/scripts/check-styled-scrollbars.mjs deleted file mode 100644 index 12317998e25..00000000000 --- a/config/scripts/check-styled-scrollbars.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { pathToFileURL } from 'node:url' -import process from 'node:process' - -import { reportUnstyledScrollbars } from './styled-scrollbars/styled-scrollbar-jsx-check.mjs' -export { - plainClassName, - reportUnstyledScrollbars -} from './styled-scrollbars/styled-scrollbar-jsx-check.mjs' - -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) -const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) - -export function normalizePath(root, filePath) { - return path.relative(root, filePath).split(path.sep).join('/') -} - -function isSkippedFile(root, filePath) { - const relative = normalizePath(root, filePath) - if (relative.includes('.test.') || relative.includes('.spec.')) { - return true - } - return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) -} - -async function collectSourceFiles(root, dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }) - const files = [] - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - if (!SKIP_PATH_PARTS.has(entry.name)) { - files.push(...(await collectSourceFiles(root, fullPath))) - } - } else if ( - entry.isFile() && - SOURCE_EXTENSIONS.has(path.extname(entry.name)) && - !isSkippedFile(root, fullPath) - ) { - files.push(fullPath) - } - } - - return files -} - -async function collectUnstyledScrollbarReports(root) { - const sourceRoot = path.join(root, 'src', 'renderer', 'src') - const files = await collectSourceFiles(root, sourceRoot) - const reports = [] - - for (const filePath of files) { - const sourceText = await fs.readFile(filePath, 'utf8') - reports.push(...reportUnstyledScrollbars(filePath, sourceText)) - } - - return reports -} - -function formatReports(root, reports) { - return reports - .map( - (report) => - `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.text.replace(/\s+/g, ' ')}` - ) - .join('\n') -} - -export async function main(root = process.cwd()) { - const reports = await collectUnstyledScrollbarReports(root) - if (reports.length === 0) { - return 0 - } - - console.error('Renderer vertical scroll containers must use an Orca scrollbar style.') - console.error('Put the scrollbar class in the same class literal as the vertical overflow class.') - console.error('Use scrollbar-sleek, scrollbar-editor, or worktree-sidebar-scrollbar.') - console.error('') - console.error(formatReports(root, reports)) - return 1 -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(await main()) -} diff --git a/config/scripts/check-styled-scrollbars.test.mjs b/config/scripts/check-styled-scrollbars.test.mjs deleted file mode 100644 index c6ff4a72a95..00000000000 --- a/config/scripts/check-styled-scrollbars.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { plainClassName, reportUnstyledScrollbars } from './check-styled-scrollbars.mjs' - -describe('check-styled-scrollbars', () => { - it('reports renderer vertical scroll containers without an Orca scrollbar style', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts obvious styled vertical scroll containers', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('does not accept nonexistent scrollbar classes as Orca scrollbar styles', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('fails closed when a separate class composer argument supplies the scrollbar style', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts static class composer arguments when the same literal is styled', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('fails closed when a scrollbar class is only conditionally present', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example({ enabled }) { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts conditional branches when overflow and scrollbar live in the same class literal', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example({ enabled }) { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('reports vertical scroll inside arbitrary wrappers when the literal is unstyled', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('does not require a vertical scrollbar style for horizontal-only overflow', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
 }'
-    )
-
-    expect(reports).toHaveLength(0)
-  })
-
-  it('does not let responsive scrollbar variants satisfy unconditional overflow', () => {
-    const reports = reportUnstyledScrollbars(
-      'Example.tsx',
-      'export function Example() { return 
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts matching responsive overflow and scrollbar variants', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('accepts unconditional scrollbar styles for responsive overflow', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('reports inline vertical overflow without an Orca scrollbar class', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts inline vertical overflow with a stable Orca scrollbar class', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(0) - }) - - it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => { - for (const classNameExpression of [ - "enabled && 'scrollbar-sleek'", - "enabled ? 'scrollbar-sleek' : undefined", - "enabled || 'scrollbar-sleek'", - "enabled ?? 'scrollbar-sleek'" - ]) { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - `export function Example({ enabled }) { return
}` - ) - - expect(reports, classNameExpression).toHaveLength(1) - } - }) - - it('reports logical inline style spreads without an Orca scrollbar class', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example({ open }) { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('reports JSX spread className props with unstyled vertical overflow', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(1) - }) - - it('accepts JSX spread className props when the same literal is styled', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('uses later spread className props over earlier explicit className props', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - 'export function Example() { return
}' - ) - - expect(reports).toHaveLength(1) - }) - - it('supports variant helper className config', () => { - const reports = reportUnstyledScrollbars( - 'Example.tsx', - "export function Example() { return
}" - ) - - expect(reports).toHaveLength(0) - }) - - it('normalizes Tailwind variants and important prefixes before matching', () => { - expect(plainClassName('md:overflow-y-auto')).toBe('overflow-y-auto') - expect(plainClassName('[&:hover]:overflow-y-auto')).toBe('overflow-y-auto') - expect(plainClassName('md:!scrollbar-editor')).toBe('scrollbar-editor') - expect(plainClassName('!scrollbar-editor')).toBe('scrollbar-editor') - }) -}) diff --git a/config/scripts/oxlint-plugin-test-runner.mjs b/config/scripts/oxlint-plugin-test-runner.mjs new file mode 100644 index 00000000000..c5a80e0c106 --- /dev/null +++ b/config/scripts/oxlint-plugin-test-runner.mjs @@ -0,0 +1,58 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' + +const oxlintPackageDirectory = path.dirname( + createRequire(import.meta.url).resolve('oxlint/package.json') +) +const oxlintPath = path.join(oxlintPackageDirectory, 'bin', 'oxlint') + +export function runOxlintPluginOnSource({ + pluginName, + pluginPath, + rules, + source, + extension = 'tsx' +}) { + const directory = mkdtempSync(path.join(tmpdir(), `orca-${pluginName}-lint-`)) + const sourcePath = path.join(directory, `sample.${extension}`) + const configPath = path.join(directory, 'oxlint.json') + + try { + writeFileSync(sourcePath, source) + writeFileSync( + configPath, + JSON.stringify({ + plugins: [], + categories: { + correctness: 'off', + suspicious: 'off', + pedantic: 'off', + perf: 'off', + style: 'off', + restriction: 'off', + nursery: 'off' + }, + jsPlugins: [{ name: pluginName, specifier: pluginPath }], + rules + }) + ) + const result = spawnSync( + process.execPath, + [oxlintPath, '--config', configPath, '--format', 'json', sourcePath], + { encoding: 'utf8' } + ) + if (result.error) { + throw result.error + } + if (!result.stdout.trim()) { + throw new Error(result.stderr || `${pluginName} did not produce Oxlint output`) + } + return JSON.parse(result.stdout).diagnostics + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/config/scripts/quadratic-buffer-concat-plugin.test.mjs b/config/scripts/quadratic-buffer-concat-plugin.test.mjs new file mode 100644 index 00000000000..68c47a0d661 --- /dev/null +++ b/config/scripts/quadratic-buffer-concat-plugin.test.mjs @@ -0,0 +1,113 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/quadratic-buffer-concat.mjs') + +function lintSource(source) { + return runOxlintPluginOnSource({ + pluginName: 'quadratic-buffer-concat', + pluginPath, + source, + extension: 'ts', + rules: { + 'quadratic-buffer-concat/no-loop-carried-concat': 'warn' + } + }) +} + +const violations = [ + [ + 'self accumulator', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }', + 'acc' + ], + [ + 'trailing accumulator', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }', + 'acc' + ], + [ + 'spread self accumulator', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }', + 'acc' + ], + [ + 'indirect stream carry', + `async function read(stream) { + let remainder = null + for await (const chunk of stream) { + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + remainder = Buffer.from(data.subarray(lineStart)) + } + }`, + 'remainder' + ], + [ + 'indirect transcript carry', + `function read(fd, size) { + let carryBytes = Buffer.alloc(0) + while (bytesRead < size) { + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + carryBytes = combined.subarray(0, firstNewline) + } + }`, + 'carryBytes' + ], + [ + 'classic for initializer', + 'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }', + 'acc' + ], + [ + 'class field accumulator', + 'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }', + 'this.pending' + ], + [ + 'guarded accumulator', + 'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }', + 'acc' + ] +] + +const accepted = [ + [ + 'single concat after loop', + 'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)' + ], + [ + 'spread chunk list', + `let carryChunks = [] + while (scanEnd > 0) { + const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + }` + ], + [ + 'iteration-local result', + 'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }' + ], + [ + 'iteration-local accumulator', + 'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }' + ], + [ + 'no enclosing loop', + 'class S { handle(chunk) { const buffer = Buffer.concat([this.pending, chunk]); this.pending = parse(buffer).pending } }' + ], + ['no Buffer concat', 'export const x = 1'] +] + +describe('quadratic Buffer.concat Oxlint plugin', () => { + it.each(violations)('reports %s', (_name, source, accumulator) => { + const diagnostics = lintSource(source) + + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0].message).toContain(accumulator) + }) + + it.each(accepted)('accepts %s', (_name, source) => { + expect(lintSource(source)).toEqual([]) + }) +}) diff --git a/config/scripts/renderer-scrollbar-style-plugin.test.mjs b/config/scripts/renderer-scrollbar-style-plugin.test.mjs new file mode 100644 index 00000000000..fd67ffbbd84 --- /dev/null +++ b/config/scripts/renderer-scrollbar-style-plugin.test.mjs @@ -0,0 +1,116 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { plainClassName } from '../oxlint-plugins/renderer-scrollbar-style.mjs' +import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs' + +const pluginPath = path.resolve('config/oxlint-plugins/renderer-scrollbar-style.mjs') + +function lintSource(source) { + return runOxlintPluginOnSource({ + pluginName: 'renderer-scrollbar-style', + pluginPath, + source, + rules: { + 'renderer-scrollbar-style/require-styled-vertical-scrollbar': 'warn' + } + }) +} + +const violations = [ + ['unstyled class', 'export const X = () =>
'], + [ + 'unstyled suffix-important class', + 'export const X = () =>
' + ], + [ + 'unknown scrollbar class', + 'export const X = () =>
' + ], + [ + 'separate class composer arguments', + "export const X = () =>
" + ], + [ + 'conditional scrollbar', + "export const X = ({ enabled }) =>
" + ], + [ + 'arbitrary class wrapper', + "export const X = () =>
" + ], + [ + 'mismatched responsive variants', + 'export const X = () =>
' + ], + ['inline overflow', "export const X = () =>
"], + [ + 'logical inline style spread', + "export const X = ({ open }) =>
" + ], + ['JSX spread class', "export const X = () =>
"], + [ + 'later spread override', + 'export const X = () =>
' + ] +] + +const accepted = [ + [ + 'styled vertical class', + 'export const X = () =>
' + ], + [ + 'styled suffix-important classes', + 'export const X = () =>
' + ], + [ + 'same composer literal', + "export const X = () =>
" + ], + [ + 'same conditional literal', + "export const X = ({ enabled }) =>
" + ], + ['horizontal-only overflow', 'export const X = () =>
'],
+  [
+    'matching responsive variants',
+    'export const X = () => 
' + ], + [ + 'unconditional scrollbar', + 'export const X = () =>
' + ], + [ + 'styled inline overflow', + 'export const X = () =>
' + ], + [ + 'styled JSX spread class', + "export const X = () =>
" + ], + [ + 'variant configuration', + "export const X = () =>
" + ] +] + +describe('renderer scrollbar style Oxlint plugin', () => { + it.each(violations)('reports %s', (_name, source) => { + expect(lintSource(source)).toHaveLength(1) + }) + + it.each(accepted)('accepts %s', (_name, source) => { + expect(lintSource(source)).toEqual([]) + }) + + it.each([ + ['md:overflow-y-auto', 'overflow-y-auto'], + ['[&:hover]:overflow-y-auto', 'overflow-y-auto'], + ['md:!scrollbar-editor', 'scrollbar-editor'], + ['!scrollbar-editor', 'scrollbar-editor'], + ['overflow-y-auto!', 'overflow-y-auto'], + ['md:scrollbar-editor!', 'scrollbar-editor'] + ])('normalizes %s', (token, expected) => { + expect(plainClassName(token)).toBe(expected) + }) +}) diff --git a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs b/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs deleted file mode 100644 index 2a9d13b6fee..00000000000 --- a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs +++ /dev/null @@ -1,312 +0,0 @@ -// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. -import ts from 'typescript-api' - -const STYLED_SCROLLBAR_CLASSES = new Set( - 'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ') -) -// Why: vertical scroll is where native scrollbar drift keeps recurring. The -// guard intentionally ignores horizontal-only overflow. -const VERTICAL_SCROLL_CLASSES = new Set( - 'overflow-auto overflow-scroll overflow-y-auto overflow-y-scroll'.split(' ') -) -const VERTICAL_SCROLL_STYLE_VALUES = new Set(['auto', 'scroll']) - -export function plainClassName(token) { - const normalizedToken = token.startsWith('!') ? token.slice(1) : token - const parts = [] - let bracketDepth = 0 - let currentPart = '' - - for (const char of normalizedToken) { - if (char === '[') { - bracketDepth += 1 - } else if (char === ']') { - bracketDepth = Math.max(0, bracketDepth - 1) - } - - if (char === ':' && bracketDepth === 0) { - parts.push(currentPart) - currentPart = '' - continue - } - currentPart += char - } - - parts.push(currentPart) - const className = parts.at(-1) ?? '' - return className.startsWith('!') ? className.slice(1) : className -} - -function classTokenParts(token) { - const variants = [] - let bracketDepth = 0 - let currentPart = '' - - for (const char of token.startsWith('!') ? token.slice(1) : token) { - if (char === '[') { - bracketDepth += 1 - } else if (char === ']') { - bracketDepth = Math.max(0, bracketDepth - 1) - } - if (char === ':' && bracketDepth === 0) { - variants.push(currentPart) - currentPart = '' - continue - } - currentPart += char - } - - return { className: plainClassName(token), variants: variants.filter(Boolean) } -} - -function classTokens(text) { - return text.split(/\s+/).filter(Boolean).map(classTokenParts) -} - -function sameVariants(left, right) { - return left.length === right.length && left.every((variant, index) => variant === right[index]) -} - -function literalHasScrollbarForVertical(text, verticalToken) { - return classTokens(text).some((candidate) => { - if (!STYLED_SCROLLBAR_CLASSES.has(candidate.className)) { - return false - } - return ( - candidate.variants.length === 0 || sameVariants(candidate.variants, verticalToken.variants) - ) - }) -} - -function uncoveredVerticalClass(text) { - return classTokens(text).find((token) => { - return ( - VERTICAL_SCROLL_CLASSES.has(token.className) && !literalHasScrollbarForVertical(text, token) - ) - }) -} - -function reportAt(node, filePath, sourceFile, text) { - const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) - return { - filePath, - line: position.line + 1, - column: position.character + 1, - text - } -} - -function stringLiteralTexts(node) { - if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - return [node.text] - } - if (!ts.isTemplateExpression(node)) { - return [] - } - return [node.head.text, ...node.templateSpans.map((span) => span.literal.text)] -} - -function collectClassLiteralReports(node, filePath, sourceFile) { - const reports = [] - - function visit(current) { - for (const text of stringLiteralTexts(current)) { - const uncovered = uncoveredVerticalClass(text) - if (uncovered) { - reports.push(reportAt(current, filePath, sourceFile, uncovered.className)) - } - } - - ts.forEachChild(current, visit) - } - - visit(node) - return reports -} - -function expressionHasStyledScrollbarLiteral(node) { - let hasStyledScrollbar = false - - function visit(current) { - if (hasStyledScrollbar) { - return - } - if ( - stringLiteralTexts(current).some((text) => - classTokens(text).some((token) => STYLED_SCROLLBAR_CLASSES.has(token.className)) - ) - ) { - hasStyledScrollbar = true - return - } - // Why: a scrollbar literal that only renders on some branches must not be - // treated as covering an unconditional inline overflow. Skip conditional - // and short-circuit expressions when proving unconditional coverage. - if (ts.isConditionalExpression(current)) { - return - } - if ( - ts.isBinaryExpression(current) && - (current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken || - current.operatorToken.kind === ts.SyntaxKind.BarBarToken || - current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) - ) { - return - } - ts.forEachChild(current, visit) - } - - visit(node) - return hasStyledScrollbar -} - -function propertyNameText(name) { - if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { - return name.text - } - if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) { - return name.expression.text - } - return undefined -} - -function styleValueIsVerticalScroll(propertyName, value) { - const parts = value.trim().toLowerCase().split(/\s+/).filter(Boolean) - if (parts.length === 0) { - return false - } - if (propertyName === 'overflowY' || propertyName === 'overflow-y') { - return VERTICAL_SCROLL_STYLE_VALUES.has(parts[0]) - } - if (propertyName !== 'overflow') { - return false - } - const verticalValue = parts.length > 1 ? parts[1] : parts[0] - return VERTICAL_SCROLL_STYLE_VALUES.has(verticalValue) -} - -function collectStyleReports(node, filePath, sourceFile) { - const reports = [] - - function visit(current) { - if (ts.isPropertyAssignment(current)) { - const propertyName = propertyNameText(current.name) - for (const value of propertyName ? stringLiteralTexts(current.initializer) : []) { - if (styleValueIsVerticalScroll(propertyName, value)) { - reports.push(reportAt(current, filePath, sourceFile, 'inline vertical scroll')) - } - } - ts.forEachChild(current.initializer, visit) - return - } - ts.forEachChild(current, visit) - } - - visit(node) - return reports -} - -function jsxAttributeName(attribute) { - return ts.isIdentifier(attribute.name) ? attribute.name.text : undefined -} - -function jsxAttributeExpression(attribute) { - if (ts.isStringLiteral(attribute.initializer)) { - return attribute.initializer - } - if (attribute.initializer && ts.isJsxExpression(attribute.initializer)) { - return attribute.initializer.expression - } - return undefined -} - -function spreadPropExpressions(node, propName) { - if ( - ts.isParenthesizedExpression(node) || - ts.isAsExpression(node) || - ts.isSatisfiesExpression(node) - ) { - return spreadPropExpressions(node.expression, propName) - } - if (ts.isConditionalExpression(node)) { - return [ - ...spreadPropExpressions(node.whenTrue, propName), - ...spreadPropExpressions(node.whenFalse, propName) - ] - } - if (ts.isBinaryExpression(node)) { - return [ - ...spreadPropExpressions(node.left, propName), - ...spreadPropExpressions(node.right, propName) - ] - } - if (!ts.isObjectLiteralExpression(node)) { - return [] - } - return node.properties.flatMap((property) => { - if (ts.isSpreadAssignment(property)) { - return spreadPropExpressions(property.expression, propName) - } - if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === propName) { - return [property.initializer] - } - return [] - }) -} - -function jsxElementReports(node, filePath, sourceFile) { - const reports = [] - let classExpression - const styleExpressions = [] - - for (const attribute of node.attributes.properties) { - if (ts.isJsxSpreadAttribute(attribute)) { - // Why: at runtime React applies attributes in source order, so a later - // spread that supplies className overrides an earlier explicit className. - const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1) - if (spreadClassExpression) { - classExpression = spreadClassExpression - } - styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style')) - } else if (jsxAttributeName(attribute) === 'className') { - classExpression = jsxAttributeExpression(attribute) - } else if (jsxAttributeName(attribute) === 'style') { - const expression = jsxAttributeExpression(attribute) - if (expression) { - styleExpressions.push(expression) - } - } - } - - if (classExpression) { - reports.push(...collectClassLiteralReports(classExpression, filePath, sourceFile)) - } - if (classExpression && expressionHasStyledScrollbarLiteral(classExpression)) { - return reports - } - for (const expression of styleExpressions) { - reports.push(...collectStyleReports(expression, filePath, sourceFile)) - } - return reports -} - -export function reportUnstyledScrollbars(filePath, sourceText) { - const sourceFile = ts.createSourceFile( - filePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TSX - ) - const reports = [] - - function visit(node) { - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - reports.push(...jsxElementReports(node, filePath, sourceFile)) - } - ts.forEachChild(node, visit) - } - - visit(sourceFile) - return reports -} diff --git a/package.json b/package.json index 921d0e59e94..5190a892ebd 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", - "audit:code-quality:native": "oxlint --config config/oxlint-code-quality.json src config tests mobile --deny-warnings", + "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", "audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none", "check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs", @@ -22,12 +22,9 @@ "doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", - "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", - "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", - "check:quadratic-buffer-concat": "node config/scripts/check-quadratic-buffer-concat.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 4d6431880ed..7f13b6aeb47 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -38,6 +38,20 @@ async function flushAsyncTicks(count = 6): Promise { } } +async function drainFakeTimerWork(limit = 20): Promise { + await flushAsyncTicks(20) + if (!vi.isFakeTimers()) { + return + } + for (let iteration = 0; iteration < limit && vi.getTimerCount() > 0; iteration += 1) { + await vi.runOnlyPendingTimersAsync() + await flushAsyncTicks(20) + } + vi.clearAllTimers() + await flushAsyncTicks(20) + vi.clearAllTimers() +} + async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise { let iterations = 0 while (pendingTimeouts.length > 0) { @@ -941,8 +955,8 @@ describe('connectPanePty', () => { }) afterEach(async () => { - // Why: drain in-flight foreground-confirm microtasks while this test still owns the store mock, so its async fallout can't leak into (and flake) the next test. - await flushAsyncTicks(20) + // Drain deferred confirmation work before the next test replaces its store mock. + await drainFakeTimerWork() vi.useRealTimers() vi.restoreAllMocks() if (originalRequestAnimationFrame) { From df9f55990b52d60201356680d2264fde78836440 Mon Sep 17 00:00:00 2001 From: Yoyo <22124891+yoyoys@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:22:22 +0800 Subject: [PATCH 20/53] fix(browser): keep the address bar editable in a narrow toolbar (#11098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): keep the address bar editable in a narrow toolbar Every other browser toolbar control is shrink-0, so the address bar was the only flexible item and absorbed the entire squeeze: below roughly 420px of pane width it collapsed to the leading globe icon with a zero-width input. Clicking it only opened the suggestion dropdown, which inherits `--radix-popover-trigger-width` and so rendered at icon width — there was no way to type or edit a URL in that tab. Focusing a squeezed bar now lifts the form out of the toolbar flow and overlays the row edge to edge, giving a full-width editable field that navigates on Enter (and a full-width suggestion list for free). A measured slot stays in flow so the overlay cannot feed back into its own width, and the slot keeps a min width so the globe remains a real hit target instead of being overlapped by neighbouring buttons. Fixes #11090 Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE * fix(browser): use the documented floating shadow for the expanded bar STYLEGUIDE.md defines exactly three elevation levels and forbids a fourth; shadow-md was not one of them. The overlaid address bar is a floating surface, so it takes the documented floating shadow already used by the other floating surfaces in this pane. Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE * test(browser): make the narrow-toolbar regression deterministic The spec passed only from a clean profile. Two preconditions it set once are actively undone by the app: - BrowserPane re-focuses a blank tab's address bar across several animation frames plus the blank-url did-finish-load handler, so a single blur() was reverted and the bar never reached its squeezed resting state. - Startup paths re-open the right sidebar. At a fixed 700px window that leaves the pane ~70px, so the overlay had nowhere to go and the field measured 0px. Settling these separately let whichever settled first drift back while the next one ran. Re-assert them in one loop until they hold simultaneously, and size the window from the chrome actually measured instead of assuming a fixed 700px. Verified 8/8 green, and still fails at the overlay assertion when the fix is disabled, so the regression coverage stays real. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../browser-pane/BrowserAddressBar.tsx | 223 +++++++++--------- .../BrowserAddressBarSuggestionList.tsx | 46 ++++ .../browser-address-bar-expansion.test.ts | 33 +++ .../browser-address-bar-expansion.ts | 19 ++ ...browser-address-bar-narrow-toolbar.spec.ts | 171 ++++++++++++++ 5 files changed, 386 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx create mode 100644 src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts create mode 100644 src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts create mode 100644 tests/e2e/browser-address-bar-narrow-toolbar.spec.ts diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx index 973f2089740..b4e6969bf52 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx @@ -1,12 +1,14 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: dropdown visibility depends on DOM focus plus browser-history suggestions, so the close path is an imperative popover sync. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Globe, Search } from 'lucide-react' +import { Globe } from 'lucide-react' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url' import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions' +import { shouldOverlayBrowserAddressBar } from './browser-address-bar-expansion' +import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList' type BrowserAddressBarProps = { value: string @@ -38,6 +40,24 @@ export default function BrowserAddressBar({ const openedAtRef = useRef(0) const blurCloseTimerRef = useRef(null) const closingResetTimerRef = useRef(null) + const slotRef = useRef(null) + const [inlineWidth, setInlineWidth] = useState(null) + + // Why: the slot keeps its flex width even while the bar overlays the toolbar, + // so measuring it here (not the form) cannot oscillate with the overlay. + useEffect(() => { + const slot = slotRef.current + if (!slot || typeof ResizeObserver === 'undefined') { + return + } + const syncWidth = (): void => setInlineWidth(slot.getBoundingClientRect().width) + syncWidth() + const observer = new ResizeObserver(syncWidth) + observer.observe(slot) + return () => observer.disconnect() + }, []) + + const overlay = shouldOverlayBrowserAddressBar({ inlineWidth, focused: open }) const clearAddressBarTimers = useCallback((): void => { if (blurCloseTimerRef.current !== null) { @@ -338,111 +358,102 @@ export default function BrowserAddressBar({ }, [dismissSuggestions, dismissSuggestionsRef]) return ( - { - // Why: Radix fires onOpenChange(false) when it detects an outside - // interaction, but during the focus-retry loop the input may still - // hold focus. Only allow programmatic closes (setOpen(false) from - // our handlers) or genuine outside dismissals. - if (!next && inputRef.current && document.activeElement === inputRef.current) { - return - } - if (!next) { - restoreTypedQuery() - } - setOpen(next) - }} - > - -
{ - event.preventDefault() - setOpen(false) - clearSuggestionPreview() - setAutocompleteQuery(value) - onSubmit() - }} - > - - { - const nextValue = event.target.value - // Why: typing creates a new suggestion list, so keyboard selection - // should return to the derived top match instead of a stale row. - // Clearing preview state here also prevents stale hover/selection - // from repopulating the input after Cmd+A → Delete. - prePreviewValueRef.current = null - setSelectedValueOverride(null) - setAutocompleteQuery(nextValue) - onChange(nextValue) + // Why: min-w-11 keeps the leading globe a real hit target once the toolbar + // squeezes the bar away — without it neighbouring buttons overlap the only + // affordance for reopening the URL field. +
+ { + // Why: Radix fires onOpenChange(false) when it detects an outside + // interaction, but during the focus-retry loop the input may still + // hold focus. Only allow programmatic closes (setOpen(false) from + // our handlers) or genuine outside dismissals. + if (!next && inputRef.current && document.activeElement === inputRef.current) { + return + } + if (!next) { + restoreTypedQuery() + } + setOpen(next) + }} + > + + inputRef.current?.focus()} + onSubmit={(event) => { + event.preventDefault() + setOpen(false) + clearSuggestionPreview() + setAutocompleteQuery(value) + onSubmit() }} - role="combobox" - aria-expanded={open} - aria-controls="browser-history-listbox" - aria-autocomplete="list" - /> - - - {suggestions.length > 0 && ( - { - // Why: prevent the popover from stealing focus away from the - // address bar input. The user is still typing; the popover is - // an overlay of suggestions, not a focus target. - e.preventDefault() - }} - > - - - - {suggestions.map((entry) => ( - handleSelect(entry.url)} - className="flex items-center gap-2 px-3 py-2" - > - {entry.isSearch ? ( - - ) : ( - - )} -
- {entry.title} - {entry.subtitle ? ( - - {entry.subtitle} - - ) : null} -
-
- ))} -
-
-
-
- )} -
+ + { + const nextValue = event.target.value + // Why: typing creates a new suggestion list, so keyboard selection + // should return to the derived top match instead of a stale row. + // Clearing preview state here also prevents stale hover/selection + // from repopulating the input after Cmd+A → Delete. + prePreviewValueRef.current = null + setSelectedValueOverride(null) + setAutocompleteQuery(nextValue) + onChange(nextValue) + }} + role="combobox" + aria-expanded={open} + aria-controls="browser-history-listbox" + aria-autocomplete="list" + /> + + + {suggestions.length > 0 && ( + { + // Why: prevent the popover from stealing focus away from the + // address bar input. The user is still typing; the popover is + // an overlay of suggestions, not a focus target. + e.preventDefault() + }} + > + + + )} + +
) } diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx new file mode 100644 index 00000000000..4cc13077eee --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserAddressBarSuggestionList.tsx @@ -0,0 +1,46 @@ +import { Globe, Search } from 'lucide-react' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import type { BrowserAddressBarSuggestion } from './browser-address-bar-suggestions' + +type BrowserAddressBarSuggestionListProps = { + suggestions: BrowserAddressBarSuggestion[] + selectedValue: string + onSelectedValueChange: (value: string) => void + onSelect: (url: string) => void +} + +export default function BrowserAddressBarSuggestionList({ + suggestions, + selectedValue, + onSelectedValueChange, + onSelect +}: BrowserAddressBarSuggestionListProps): React.ReactElement { + return ( + + + + {suggestions.map((entry) => ( + onSelect(entry.url)} + className="flex items-center gap-2 px-3 py-2" + > + {entry.isSearch ? ( + + ) : ( + + )} +
+ {entry.title} + {entry.subtitle ? ( + {entry.subtitle} + ) : null} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts new file mode 100644 index 00000000000..8d339bb75ae --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH, + isBrowserAddressBarCollapsed, + shouldOverlayBrowserAddressBar +} from './browser-address-bar-expansion' + +describe('isBrowserAddressBarCollapsed', () => { + it('treats an unmeasured slot as roomy so the bar never overlays before layout', () => { + expect(isBrowserAddressBarCollapsed(null)).toBe(false) + }) + + it('collapses below the minimum inline width', () => { + expect(isBrowserAddressBarCollapsed(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH - 1)).toBe(true) + expect(isBrowserAddressBarCollapsed(48)).toBe(true) + }) + + it('stays inline at or above the minimum width', () => { + expect(isBrowserAddressBarCollapsed(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH)).toBe(false) + expect(isBrowserAddressBarCollapsed(640)).toBe(false) + }) +}) + +describe('shouldOverlayBrowserAddressBar', () => { + it('overlays only while a collapsed bar is focused', () => { + expect(shouldOverlayBrowserAddressBar({ inlineWidth: 48, focused: true })).toBe(true) + expect(shouldOverlayBrowserAddressBar({ inlineWidth: 48, focused: false })).toBe(false) + }) + + it('never overlays a bar that already fits', () => { + expect(shouldOverlayBrowserAddressBar({ inlineWidth: 640, focused: true })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts new file mode 100644 index 00000000000..c1959e6a950 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-address-bar-expansion.ts @@ -0,0 +1,19 @@ +// Every other browser toolbar control is shrink-0, so the address bar absorbs +// all of the squeeze when the pane narrows and ends up as the leading globe +// icon with a zero-width input. Below this slot width the bar overlays the +// toolbar while focused instead, keeping the URL editable (issue #11090). +export const BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH = 220 + +export function isBrowserAddressBarCollapsed(inlineWidth: number | null): boolean { + return inlineWidth !== null && inlineWidth < BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH +} + +export function shouldOverlayBrowserAddressBar({ + inlineWidth, + focused +}: { + inlineWidth: number | null + focused: boolean +}): boolean { + return focused && isBrowserAddressBarCollapsed(inlineWidth) +} diff --git a/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts b/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts new file mode 100644 index 00000000000..169c95e0631 --- /dev/null +++ b/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts @@ -0,0 +1,171 @@ +/** + * E2E regression for issue #11090: in a narrow browser pane every toolbar + * button stays shrink-0, so the address bar absorbed the whole squeeze and + * became an unusable globe icon with a zero-width input. Focusing it must now + * overlay the toolbar with a typable field that navigates on Enter. + */ + +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { expect, test } from './helpers/orca-app' +import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test' +import { + ensureTerminalVisible, + getActiveTabType, + getActiveWorktreeId, + getBrowserTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH } from '../../src/renderer/src/components/browser-pane/browser-address-bar-expansion' + +// Why: the toolbar must land in a band — squeezed enough that the inline field +// collapses, roomy enough that the overlay itself has somewhere to go. Target +// the middle of that band rather than a fixed window width, because how much +// chrome flanks the pane (left sidebar, and a right sidebar that other startup +// paths may re-open) varies between runs. +const TARGET_TOOLBAR_WIDTH = 420 +const MIN_USABLE_TOOLBAR_WIDTH = BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH + 100 +const NARROW_WINDOW_HEIGHT = 800 + +async function startDestinationServer(): Promise<{ url: string; close: () => Promise }> { + const server = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end( + 'Typed destinationok' + ) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/typed`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } +} + +async function createBlankBrowserTab(page: Page, worktreeId: string): Promise { + await page.evaluate((targetWorktreeId) => { + window.__store?.getState().createBrowserTab(targetWorktreeId, 'about:blank', { + title: 'Narrow toolbar tab', + activate: true + }) + }, worktreeId) + await expect.poll(async () => getActiveTabType(page), { timeout: 10_000 }).toBe('browser') +} + +async function setWindowWidth(electronApp: ElectronApplication, width: number): Promise { + await electronApp.evaluate( + ({ BrowserWindow }, size) => { + const window = BrowserWindow.getAllWindows()[0] + if (!window) { + throw new Error('No Electron window') + } + window.setSize(size.width, size.height) + }, + { width: Math.round(width), height: NARROW_WINDOW_HEIGHT } + ) +} + +function browserToolbar(page: Page): Locator { + return page.locator('[data-contextual-tour-target="browser-toolbar"]').first() +} + +async function toolbarWidth(page: Page): Promise { + return browserToolbar(page).evaluate((node) => Math.round(node.getBoundingClientRect().width)) +} + +function addressBarInput(page: Page): Locator { + return page.locator('[data-orca-browser-address-bar="true"]') +} + +function addressBarOverlay(page: Page): Locator { + return page.locator('[data-orca-browser-address-bar-overlay="true"]') +} + +async function addressBarInputWidth(page: Page): Promise { + return addressBarInput(page).evaluate((node) => node.getBoundingClientRect().width) +} + +/** + * Drive the app to the resting state this spec is about: a squeezed-but-usable + * toolbar whose address bar is collapsed and unfocused. + * + * Why one loop rather than three: both preconditions are actively fought by the + * app. Startup paths re-open the right sidebar (which alone leaves the pane + * ~70px, too narrow for the overlay to have anywhere to go), and BrowserPane + * re-focuses a blank tab's address bar across several animation frames plus the + * blank-url did-finish-load handler. Settling them separately just lets whichever + * settled first drift back while the next one runs, so re-assert all of them + * together until they hold at the same time. The interval must clear the 200ms + * blur-close timer in BrowserAddressBar for the collapse to register. + */ +async function settleToSqueezedRestingState( + page: Page, + electronApp: ElectronApplication +): Promise { + await expect + .poll( + async () => { + await page.evaluate(() => { + window.__store?.getState().setRightSidebarOpen(false) + }) + const [innerWidth, toolbar] = await Promise.all([ + page.evaluate(() => window.innerWidth), + toolbarWidth(page) + ]) + // Chrome flanking the pane is everything the toolbar didn't get. + await setWindowWidth(electronApp, innerWidth - toolbar + TARGET_TOOLBAR_WIDTH) + await addressBarInput(page).evaluate((node) => node.blur()) + return { + toolbar: (await toolbarWidth(page)) > MIN_USABLE_TOOLBAR_WIDTH, + collapsed: (await addressBarOverlay(page).count()) === 0 + } + }, + { timeout: 30_000, intervals: [300, 300, 300, 500, 500, 1000] } + ) + .toEqual({ toolbar: true, collapsed: true }) +} + +test.describe('Browser address bar in a narrow toolbar', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('focusing the squeezed address bar expands a typable field that navigates', async ({ + orcaPage, + electronApp + }) => { + const destination = await startDestinationServer() + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + await createBlankBrowserTab(orcaPage, worktreeId) + await settleToSqueezedRestingState(orcaPage, electronApp) + + const overlay = addressBarOverlay(orcaPage) + // The bug: the inline field is squeezed away entirely. + await expect.poll(() => addressBarInputWidth(orcaPage), { timeout: 10_000 }).toBeLessThan(40) + + await orcaPage.locator('form:has(> [data-orca-browser-address-bar="true"])').click() + + await expect(overlay).toBeVisible() + await expect + .poll(() => addressBarInputWidth(orcaPage), { timeout: 5_000 }) + .toBeGreaterThan(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH / 2) + + await addressBarInput(orcaPage).fill(destination.url) + await addressBarInput(orcaPage).press('Enter') + + await expect + .poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).at(-1)?.url ?? null, { + timeout: 15_000 + }) + .toContain('/typed') + } finally { + await destination.close() + } + }) +}) From 77d4c64f7a05e7fb2caa48e3d0acad5db39ff1f2 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Tue, 28 Jul 2026 00:36:25 -0700 Subject: [PATCH 21/53] Improve orchestration migration safety for live legacy workers (#11107) * fix(orchestration): clarify legacy migration safety * fix(cli): sanitize legacy formatted messages * test(runtime): allow near-cap fuzz under shard load --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../orchestration-skill-guidance.test.mjs | 12 ++ skill-guides/orchestration.md | 17 ++- src/cli/bundled-skill-guides.ts | 2 +- .../orchestration-legacy-read-only.test.ts | 142 ++++++++++++++++++ src/cli/handlers/orchestration.ts | 38 ++++- src/main/runtime/orchestration/db.ts | 3 +- .../runtime/orchestration/formatter.test.ts | 11 ++ src/main/runtime/orchestration/formatter.ts | 23 +-- .../orchestration-migration-behavior.test.ts | 48 ++++++ .../runtime/rpc/methods/orchestration.test.ts | 12 +- src/main/runtime/rpc/methods/orchestration.ts | 15 +- ...al-output-frame-chunks-equivalence.test.ts | 2 +- src/shared/orchestration-rpc-contract.ts | 2 + 13 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 src/cli/handlers/orchestration-legacy-read-only.test.ts diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 74fe975048a..74d14a9a9a1 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -63,6 +63,18 @@ describe('orchestration skill guidance', () => { expect(migration).toContain('task-list --run run_legacy_local') expect(migration).toContain('Read-only inspection never consumes legacy mail') expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain') + expect(migration).toContain('does not cancel the prior assignment') + expect(migration).toContain('invalidate its worktree') + expect(migration).toContain('discard filesystem changes') + expect(migration).toContain('leave it as the only editor in that worktree') + expect(migration).toContain('observe it manually, read-only') + expect(migration).toContain('until it reaches a stable handoff point') + expect(migration).toContain('visible activity is a reason to keep observing') + expect(migration).toContain( + 'Never launch a replacement editor in the same worktree while the legacy worker may still write there.' + ) + expect(migration).toContain('if remaining work needs new lifecycle supervision') + expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`') }) it('treats long-running worker waits as liveness checkpoints, not failures', () => { diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index c1ac7d5d8f7..ed11474d97e 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -60,22 +60,29 @@ If a command returns `orchestration_migration_required`, `run_required`, or a li 1. Confirm `effectsApplied` is `false`. 2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`. 3. Read the guide completely. Do not retry the rejected command unchanged. -4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`. -5. Inspect any pre-upgrade terminal before creating replacement work. +4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement. +5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point. +6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement. The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. -Pre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection: +The cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects. + +Legacy database rows and terminal output remain available for explicit read-only inspection: ```bash orca orchestration run-list --json orca orchestration run-show --id run_legacy_local --json orca orchestration task-list --run run_legacy_local --json orca orchestration inbox --full --json -orca orchestration check --terminal --peek --json +orca orchestration check --terminal --peek --format --json +orca terminal read --terminal --json +orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json ``` -Read-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run. +Read-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands. + +Never launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run. ## Ownership diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 8b6e0d41847..93832dea9fe 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.\n\nIf a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:\n\n1. Confirm `effectsApplied` is `false`.\n2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.\n3. Read the guide completely. Do not retry the rejected command unchanged.\n4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`.\n5. Inspect any pre-upgrade terminal before creating replacement work.\n\nThe arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.\n\nPre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection:\n\n```bash\norca orchestration run-list --json\norca orchestration run-show --id run_legacy_local --json\norca orchestration task-list --run run_legacy_local --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --json\n```\n\nRead-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.\n\nIf a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:\n\n1. Confirm `effectsApplied` is `false`.\n2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.\n3. Read the guide completely. Do not retry the rejected command unchanged.\n4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement.\n5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point.\n6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement.\n\nThe arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.\n\nThe cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects.\n\nLegacy database rows and terminal output remain available for explicit read-only inspection:\n\n```bash\norca orchestration run-list --json\norca orchestration run-show --id run_legacy_local --json\norca orchestration task-list --run run_legacy_local --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nRead-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands.\n\nNever launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/orchestration-legacy-read-only.test.ts b/src/cli/handlers/orchestration-legacy-read-only.test.ts new file mode 100644 index 00000000000..2a4037fa7a9 --- /dev/null +++ b/src/cli/handlers/orchestration-legacy-read-only.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +beforeEach(() => { + callMock.mockReset() + vi.mocked(printResult).mockReset() +}) + +describe('legacy orchestration CLI inspection', () => { + it('labels legacy rows in plain check output', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status' + } + ], + count: 1 + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + expect(formatter?.(result)).toContain('msg_legacy [legacy, read-only]') + }) + + it('rebuilds legacy formatted output without runtime-supplied actions', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + subject: 'progress', + type: 'status', + body: 'Tests are running.', + payload: '{"phase":"testing"}' + } + ], + count: 1, + formatted: '[Reply: orca orchestration reply --id msg_legacy --from term_coord --body "..."]' + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + const output = formatter?.(result) + expect(output).toContain('msg_legacy [legacy, read-only]') + expect(output).toContain('Tests are running.') + expect(output).toContain('[payload] {"phase":"testing"}') + expect(output).not.toContain('[Reply:') + expect(output).not.toContain('orchestration reply') + }) + + it('preserves runtime formatting when every message belongs to a current Run', async () => { + const result = { + messages: [ + { + id: 'msg_current', + run_id: 'run_current', + from_handle: 'term_worker', + subject: 'question' + } + ], + count: 1, + formatted: '[Reply: current Run action]' + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration check']({ + flags: new Map([ + ['terminal', 'term_coord'], + ['peek', true], + ['format', true] + ]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + expect(formatter?.(result)).toBe(result.formatted) + }) + + it('labels legacy rows in full inbox output without hiding their body', async () => { + const result = { + messages: [ + { + id: 'msg_legacy', + run_id: 'run_legacy_local', + from_handle: 'term_worker', + to_handle: 'term_coord', + subject: 'progress', + body: 'Tests are running.' + } + ], + count: 1 + } + callMock.mockResolvedValue({ result }) + + await ORCHESTRATION_HANDLERS['orchestration inbox']({ + flags: new Map([['full', true]]), + client: { call: callMock }, + cwd: '/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] + const output = formatter?.(result) + expect(output).toContain('msg_legacy [legacy, read-only]') + expect(output).toContain('Tests are running.') + }) +}) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 220f0964251..09259b4ae60 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -22,6 +22,7 @@ import type { import type { NativeChatMessage } from '../../shared/native-chat-types' import type { RuntimeTerminalRead } from '../../shared/runtime-types' import { + ORCHESTRATION_LEGACY_RUN_ID, orchestrationMigrationData, orchestrationSkillRecoveryData } from '../../shared/orchestration-rpc-contract' @@ -76,6 +77,7 @@ const TASK_STATUS_VALUES = [ type MessageSummary = { id: string + run_id?: string from_handle: string to_handle?: string subject: string @@ -85,6 +87,31 @@ type MessageSummary = { read?: number } +function formatMessageReadOnlyTag(message: MessageSummary): string { + return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : '' +} + +function isLegacyReadOnlyMessage(message: MessageSummary): boolean { + return message.run_id === ORCHESTRATION_LEGACY_RUN_ID +} + +function formatLegacyAwareCheckMessages(messages: MessageSummary[]): string { + return messages + .map((message) => { + const lines = [ + `${message.id}${formatMessageReadOnlyTag(message)} [${message.type ?? 'status'}] from=${message.from_handle} "${message.subject}"` + ] + if (message.body) { + lines.push(message.body) + } + if (message.payload) { + lines.push(`[payload] ${message.payload}`) + } + return lines.join('\n') + }) + .join('\n\n') +} + type LifecycleSendRejection = { action: 'rejected' code: string @@ -617,7 +644,9 @@ export const ORCHESTRATION_HANDLERS: Record = { } printResult(result, json, (r) => { if (r.formatted) { - return r.formatted + return r.messages.some(isLegacyReadOnlyMessage) + ? formatLegacyAwareCheckMessages(r.messages) + : r.formatted } if (r.count === 0) { if (r.timedOut) { @@ -631,7 +660,10 @@ export const ORCHESTRATION_HANDLERS: Record = { return 'No messages.' } const rendered = r.messages - .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) + .map( + (m) => + `${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"` + ) .join('\n') return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered }) @@ -669,7 +701,7 @@ export const ORCHESTRATION_HANDLERS: Record = { // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. return r.messages .map((m) => { - const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` + const head = `${m.id}${formatMessageReadOnlyTag(m)} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` if (!full) { return head } diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 04fcb29fb55..7dd39a61413 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -31,6 +31,7 @@ import type { FederationRelayItemRow } from './types' import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' import { parsePaneKey } from '../../../shared/stable-pane-id' import { OrchestrationError } from './orchestration-error' @@ -140,7 +141,7 @@ function exposeQuestionTimestamps(question: QuestionRow): QuestionRow { } } -export const LEGACY_RUN_ID = 'run_legacy_local' +export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state. const SCHEMA_VERSION = 17 diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index de71c21ded5..54f13a477a4 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -77,6 +77,17 @@ describe('formatMessageBanner', () => { ) }) + it('marks legacy messages read-only without reply or acknowledgment affordances', () => { + const banner = formatMessageBanner( + makeMessage({ id: 'msg_legacy', run_id: 'run_legacy_local' }) + ) + + expect(banner).toContain('[LEGACY READ-ONLY]') + expect(banner).toContain('[Inspection only: reply and acknowledgment are unavailable.]') + expect(banner).not.toContain('[Reply:') + expect(banner).not.toContain('orchestration reply') + }) + it('ends with a separator line', () => { const banner = formatMessageBanner(makeMessage()) const lines = banner.split('\n') diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index a86f3c61edf..e7ca26558bf 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -1,21 +1,23 @@ import type { MessageRow } from './types' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' const BANNER_WIDTH = 60 const SEPARATOR = '─'.repeat(BANNER_WIDTH) -// Why: rich message banners help agents (and humans reading terminal output) -// quickly parse message metadata. Priority indicators surface urgent messages -// visually. The reply hint reduces friction for agent-to-agent responses -// (Section 4.8). export function formatMessageBanner(msg: MessageRow): string { const priorityTag = msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : '' + const legacyReadOnly = msg.run_id === ORCHESTRATION_LEGACY_RUN_ID + const authorityTag = legacyReadOnly ? ' [LEGACY READ-ONLY]' : '' const senderName = msg.from_handle.toUpperCase() - const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag} (${msg.type}) ────` + const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag}${authorityTag} (${msg.type}) ────` const lines: string[] = [header] lines.push(`Subject: ${msg.subject}`) + if (legacyReadOnly) { + lines.push('[Inspection only: reply and acknowledgment are unavailable.]') + } if (msg.body) { lines.push(msg.body) @@ -25,11 +27,12 @@ export function formatMessageBanner(msg: MessageRow): string { lines.push(`[Payload: ${msg.payload}]`) } - // Why: injected reply commands must retain the receiving pane's identity - // even when an older shell lacks Orca's terminal environment variables. - lines.push( - `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` - ) + if (!legacyReadOnly) { + // Why: older shells can lack Orca's terminal identity environment. + lines.push( + `[Reply: orca orchestration reply --id ${msg.id} --from ${msg.to_handle} --body "..."]` + ) + } lines.push(SEPARATOR) return lines.join('\n') diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts index ce76d7b611f..87b482dc304 100644 --- a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -49,6 +49,54 @@ describe('orchestration migration behavior', () => { expect(db.getTask(task.id)?.status).toBe('ready') }) + it('formats legacy terminal inspection as read-only without consuming mail', async () => { + const { db, runtime } = createRuntime() + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coord', + subject: 'still working', + body: 'Tests are running.' + }) + const check = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.check')! + + const inspected = (await check.handler( + check.params!.parse({ terminal: 'term_coord', peek: true, format: true }), + { runtime } + )) as { count: number; formatted: string } + + expect(inspected.count).toBe(1) + expect(inspected.formatted).toContain('[LEGACY READ-ONLY]') + expect(inspected.formatted).toContain('Tests are running.') + expect(inspected.formatted).not.toContain('[Reply:') + expect(db.getMessageById(message.id)?.read).toBe(0) + }) + + it('rejects replies to legacy mail without marking or inserting rows', async () => { + const { db, runtime } = createRuntime() + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coord', + subject: 'legacy question' + }) + const reply = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.reply')! + + await expect( + reply.handler( + reply.params!.parse({ + id: message.id, + body: 'replacement started', + from: 'term_coord' + }), + { runtime } + ) + ).rejects.toMatchObject({ + code: 'legacy_read_only', + data: { effectsApplied: false } + }) + expect(db.getMessageById(message.id)?.read).toBe(0) + expect(db.getInbox(100)).toHaveLength(1) + }) + it('rejects a pre-contract worker_done before message or lifecycle mutation', async () => { const { db, runtime } = createRuntime() const run = db.createRun({ diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 899a94fa190..d1f04a33588 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1529,17 +1529,25 @@ describe('orchestration RPC methods', () => { describe('orchestration.reply', () => { it('replies to a message', async () => { setup() - const original = db.insertMessage({ from: 'a', to: 'b', subject: 'question' }) + const original = db.insertMessage({ + from: 'a', + to: 'b', + subject: 'question', + runId: activeRunId + }) const result = (await call('orchestration.reply', { id: original.id, body: 'answer', from: 'b' - })) as { message: { to_handle: string; subject: string; thread_id: string } } + })) as { + message: { to_handle: string; subject: string; thread_id: string; run_id: string } + } expect(result.message.to_handle).toBe('a') expect(result.message.subject).toBe('Re: question') expect(result.message.thread_id).toBe(original.id) + expect(result.message.run_id).toBe(activeRunId) }) it('throws on nonexistent message', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 128520f4c33..5bafa6fed9b 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -9,7 +9,10 @@ import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' -import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' +import { + ORCHESTRATION_LEGACY_RUN_ID, + orchestrationSkillRecoveryData +} from '../../../../shared/orchestration-rpc-contract' import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' @@ -899,6 +902,13 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!original) { throw new Error(`Message not found: ${params.id}`) } + if (original.run_id === ORCHESTRATION_LEGACY_RUN_ID) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; no reply was applied.', + { effectsApplied: false } + ) + } const question = db.getQuestion(params.id) if (question) { @@ -943,7 +953,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ to: original.from_handle, subject: `Re: ${original.subject}`, body: params.body, - threadId: original.thread_id ?? original.id + threadId: original.thread_id ?? original.id, + runId: original.run_id }) runtime.notifyMessageArrived(original.from_handle, reply.type) diff --git a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts index 538e700e7a8..3de93dcfe0f 100644 --- a/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts +++ b/src/main/runtime/rpc/terminal-output-frame-chunks-equivalence.test.ts @@ -475,7 +475,7 @@ describe('iterateTerminalOutputFrameChunks equivalence with the pre-optimization expectEquivalent(data, { seq: 88_888, rawLength: data.length }, `fuzz-cap seq trial=${trial}`) expectEquivalent(data, { seq: 88_888 }, `fuzz-cap delayed trial=${trial}`) } - }, 15_000) + }, 30_000) it('keeps every emitted frame within the wire cap and reassembles to the input', () => { const data = `${'a'.repeat(200 * 1024)}${SURROGATE_PAIR.repeat(4096)}${LONE_HIGH}` diff --git a/src/shared/orchestration-rpc-contract.ts b/src/shared/orchestration-rpc-contract.ts index 2fb372366cc..de85ef8d8bd 100644 --- a/src/shared/orchestration-rpc-contract.ts +++ b/src/shared/orchestration-rpc-contract.ts @@ -13,6 +13,8 @@ export const ORCHESTRATION_SKILL_COMMAND_ARGS = [ '--full' ] as const +export const ORCHESTRATION_LEGACY_RUN_ID = 'run_legacy_local' + const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.runCreate', 'orchestration.runUse', From 89f32a121bb1560874197bad4b8bcc7c2fa3549c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:30:52 -0700 Subject: [PATCH 22/53] fix(lint): restore nested config discovery in changed-code gate (#11130) --- config/scripts/check-changed-code-quality.mjs | 6 ++++-- config/scripts/check-changed-code-quality.test.mjs | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index a8fd5b73926..2a3542e78b9 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -6,10 +6,12 @@ import { pathToFileURL } from 'node:url' import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs' const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/ -const OXLINT_SCANS = [ +export const OXLINT_SCANS = [ { + // Why: no --config, so Oxlint keeps discovering nested configs. Pinning the root + // config would apply root rules to mobile/, whose .oxlintrc.json turns them off. label: 'code quality', - args: ['--config', '.oxlintrc.json', '--report-unused-disable-directives-severity', 'warn'] + args: ['--report-unused-disable-directives-severity', 'warn'] }, { label: 'type-aware code quality', diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 0f68172a5b5..5daca8da49e 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + OXLINT_SCANS, diagnosticTouchesAddedLines, overlapsAddedLines, parseAddedLineRanges @@ -41,4 +42,13 @@ describe('changed-code quality line matching', () => { diagnosticTouchesAddedLines(diagnostic, new Map([[file, [{ start: 24, end: 24 }]]]), root) ).toBe(true) }) + + // Why: pinning --config disables nested-config discovery, so root rules that + // mobile/.oxlintrc.json turns off would fail the gate on mobile files. + it('lets the untyped scan discover nested configs instead of pinning the root config', () => { + const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'code quality') + + expect(scan.args).not.toContain('--config') + expect(scan.args).not.toContain('--disable-nested-config') + }) }) From efcc015d69966480ff065e4c95f3637066a58206 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:36:02 -0700 Subject: [PATCH 23/53] docs: add WeChat group 6 QR with overflow guidance Show group 5 and group 6 QR codes side by side so people can join group 6 if group 5 is full. --- README.md | 5 +++-- docs/assets/wechat-qr-group6.jpg | Bin 0 -> 388987 bytes docs/readme/README.fr.md | 5 +++-- docs/readme/README.zh-CN.md | 5 +++-- 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 docs/assets/wechat-qr-group6.jpg diff --git a/README.md b/README.md index 0c8fb045410..b0ef0741e4b 100644 --- a/README.md +++ b/README.md @@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** All other groups are full, now we're on group 5. +- **WeChat:** If group 5 is full, you can join group 6. - WeChat QR code for the Orca community + WeChat group 5 QR code for the Orca community + WeChat group 6 QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/docs/assets/wechat-qr-group6.jpg b/docs/assets/wechat-qr-group6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6032ba2f8a32d13e030d9b9eb91a30c2a81b3dd2 GIT binary patch literal 388987 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 (U; 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