--- a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts @@ -139,0 +140,6 @@ +/** Divides the preset's window-DIP size by the live UI zoom factor (see `main.css`). */ +export const BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME = 'browser-page-preset-viewport' + +// Why the DIP conversion: CDP emulates the guest viewport in window DIP, but UI zoom +// redefines this renderer's CSS px, so an unconverted `${width}px` host box outgrows the +// emulated page and leaves an unpainted strip beside it (STA-7568). @@ -144,2 +150,14 @@ - viewport.content.style.width = size ? `${size.width}px` : '100%' - viewport.content.style.height = size ? `${size.height}px` : '100%' + if (size) { + viewport.content.style.setProperty('--browser-page-viewport-width', `${size.width}px`) + viewport.content.style.setProperty('--browser-page-viewport-height', `${size.height}px`) + // Why: an inline width/height would outrank the class rule's zoom division. + viewport.content.style.removeProperty('width') + viewport.content.style.removeProperty('height') + viewport.content.classList.add(BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME) + } else { + viewport.content.classList.remove(BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME) + viewport.content.style.removeProperty('--browser-page-viewport-width') + viewport.content.style.removeProperty('--browser-page-viewport-height') + viewport.content.style.width = '100%' + viewport.content.style.height = '100%' + } --- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts @@ -30 +29,0 @@ - handleExplicitlyClosedConnect?: (id: string) => boolean @@ -93,3 +91,0 @@ - if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) { - return - } --- a/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts +++ b/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts @@ -2 +1,0 @@ -import { flattenRetainedSlice } from '../../lib/flatten-retained-slice' @@ -11,4 +10 @@ - return { - data: tail.text.length < data.length ? flattenRetainedSlice(tail.text) : tail.text, - bytes: tail.bytes - } + return { data: tail.text, bytes: tail.bytes } --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -47 +46,0 @@ - let onAbandonedConnect: ((ptyId: string) => boolean) | undefined @@ -141 +139,0 @@ - handleExplicitlyClosedConnect: (id) => destroyed && (onAbandonedConnect?.(id) ?? false), @@ -273,2 +271 @@ - destroy(options) { - onAbandonedConnect ??= options?.onAbandonedConnect + destroy() { --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -13 +12,0 @@ -import { releaseTerminalScrollIntentKey } from '../../lib/pane-manager/terminal-scroll-intent-key-store' @@ -144,14 +143 @@ - const capture = capturedPanesByTabId.get(tabId) - if (capture) { - // Parked panes never run PaneManager's close teardown. Release their - // strong scroll-intent keys here or every closed parked tab leaks one per - // leaf for the renderer lifetime. - for (const pane of capture.panes) { - releaseCapturedPaneScrollIntent(pane) - } - capturedPanesByTabId.delete(tabId) - } -} - -function releaseCapturedPaneScrollIntent(pane: ParkedTerminalPaneCapture): void { - releaseTerminalScrollIntentKey(pane.leafId) + capturedPanesByTabId.delete(tabId) @@ -232,5 +217,0 @@ - for (const pane of capture.panes) { - // Worktree removal can bypass closeTab while panes are parked; release - // the same strong scroll-intent keys as explicit tab retirement. - releaseCapturedPaneScrollIntent(pane) - } --- a/src/renderer/src/components/terminal-pane/terminal-render-desync-trigger.ts +++ b/src/renderer/src/components/terminal-pane/terminal-render-desync-trigger.ts @@ -121,9 +120,0 @@ - -if (import.meta !== undefined && import.meta.hot) { - // Vite can replace this module without a full renderer reload. Remove the - // opt-in gesture hook so dev sessions do not retain stale pane closures. - import.meta.hot.dispose(() => { - removeClickListener() - stopRenderDesyncSampleBurst() - }) -} --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2735,14 +2734,0 @@ - "map": { - "agentCount": "{{count}} agents", - "agentCount_one": "{{count}} agent", - "agentCount_other": "{{count}} agents", - "host": { - "local": "Local", - "remote": "Remote", - "ssh": "SSH", - "wsl": "WSL" - }, - "liveContainmentMap": "Live containment map", - "worktreeSummary_one": "{{total}} agent · {{active}} active · {{done}} done", - "worktreeSummary_other": "{{total}} agents · {{active}} active · {{done}} done" - }, @@ -2752,5 +2737,0 @@ - }, - "view": { - "board": "Dashboard", - "label": "Dashboard view", - "map": "Agent Map" --- a/src/renderer/src/lib/input-quiet-scheduler.ts +++ b/src/renderer/src/lib/input-quiet-scheduler.ts @@ -18 +17,0 @@ -let listenersWindow: Window | null = null @@ -53,19 +51,0 @@ - listenersWindow = targetWindow -} - -function disposeInputQuietListeners(): void { - if (!listenersWindow) { - return - } - const options: AddEventListenerOptions = { capture: true } - for (const eventName of INPUT_QUIET_EVENTS) { - listenersWindow.removeEventListener(eventName, recordInput, options) - } - listenersWindow = null - listenersInstalled = false -} - -if (import.meta !== undefined && import.meta.hot) { - // Vite can replace this module without a full renderer reload. Remove the - // global input hooks so dev sessions do not retain stale scheduler closures. - import.meta.hot.dispose(disposeInputQuietListeners) --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -12,0 +13 @@ +import { registerShellMarkdownAliases } from './monaco-languages/register-shell-markdown-aliases' @@ -81,0 +83 @@ +registerShellMarkdownAliases(monaco) --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -2 +2,5 @@ -import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' @@ -45,0 +50,5 @@ +/** + * Owner rows for this id on one host, read from the repo catalog AND the detected-worktree index + * because owner provenance is split across both stores — a HUB-projected owner may appear in + * either one, and missing it would drop the transport the caller needs. + */ @@ -103,0 +113,5 @@ +/** + * `null`-returning adapter for host-qualified callers with no branch for `ambiguous` vs + * `missing`. The fail-closed decision stays in the `*Result` resolver so the two entry + * points can never disagree about what counts as an owner. + */ @@ -112,0 +127,5 @@ +/** + * An authoritative host selection already names the target, so only the transport has to be + * recovered — and only for `ssh:`, which a paired HUB can proxy. Rival HUBs projecting the same + * host stay unresolved rather than guessing one. + */ @@ -166,0 +186,4 @@ +/** + * `null`-returning adapter over the owner-routed resolver for call sites that cannot act on + * `ambiguous` — collapsing both refusals to `null` keeps them fail-closed at the call site. + */ @@ -174,0 +198,5 @@ +/** + * Owner precedence for owner-routed operations: stamped identity first, the legacy + * pre-owner-projection branches strictly below it, and an id no row can place fails closed — + * defaulting an unplaceable id to `local` would aim the operation at the wrong machine. + */ @@ -222,0 +251,14 @@ + // Why: a found repo/worktree record is positive identity evidence, so keep terminal-owner + // parity with the folder branch below. Every stamped row already routed above, so an unstamped + // repo row here is a legacy pre-owner-projection row — local by construction, as + // getRepoExecutionHostId, main's resolveRepoOwnershipEvidence and Repo.executionHostId's own + // contract all agree. Without this, the legacy hydration gates fail a genuinely local git + // worktree closed whenever any unrelated runtime is saved — the #10251 symptom, for git + // worktrees (#16733). A repo row on its own is repo identity, not worktree identity (#16841), + // so a known worktree row — listed or currently detected — must back it. + const localOwnerRoute = hasKnownWorktree + ? resolveUnstampedLocalWorktreeRoute(state, repoId) + : null + if (localOwnerRoute) { + return { kind: 'resolved', route: localOwnerRoute } + } @@ -229 +271,4 @@ - ? { kind: 'resolved', route: { executionHostId: 'local', runtimeEnvironmentId: null } } + ? { + kind: 'resolved', + route: { executionHostId: LOCAL_EXECUTION_HOST_ID, runtimeEnvironmentId: null } + } @@ -232,0 +278,21 @@ +/** + * A local route for a worktree whose only repo rows predate owner projection — the exact + * condition `resolveExplicitWorktreeOperationRouteResult` already routed above if it applied to + * any row. Reaching this function means every row for `repoId` is unstamped, so + * `getRepoExecutionHostId`'s own fallback resolves each of them to `local`; a row only has to + * exist. + */ +function resolveUnstampedLocalWorktreeRoute( + state: WorktreeOperationRouteState, + repoId: string +): WorktreeOperationRoute | null { + const hasUnstampedRepoRow = state.repos?.some((repo) => repo.id === repoId) ?? false + return hasUnstampedRepoRow + ? { executionHostId: LOCAL_EXECUTION_HOST_ID, runtimeEnvironmentId: null } + : null +} + +/** + * Folder workspaces have no repo or worktree rows, so they route off their own owner record + * instead of the legacy hydration gates above. + */ @@ -254,0 +321,5 @@ +/** + * Projects the route's runtime environment onto settings so a routed operation runs against the + * owner's environment rather than whichever one the UI has active; settings can still be absent + * during early hydration, hence the synthesized fallback. + */ --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -282,2 +282,2 @@ -export function findIndexedFolderWorkspaceOwner( - folderWorkspaces: readonly FolderWorkspaceOwnerRecord[] | undefined, +export function findIndexedFolderWorkspaceOwner( + folderWorkspaces: readonly T[] | undefined, @@ -286 +286 @@ -): FolderWorkspaceOwnerRecord | null { +): T | null { @@ -298,5 +298,6 @@ - return resolution?.kind === 'resolved' ? resolution.owner : null -} - -export function findIndexedProjectGroupOwner( - projectGroups: readonly ProjectGroupOwnerRecord[] | undefined, + // The cache is keyed by this exact array, so its owner retains the caller's row type. + return resolution?.kind === 'resolved' ? (resolution.owner as T) : null +} + +export function findIndexedProjectGroupOwner( + projectGroups: readonly T[] | undefined, @@ -305 +306 @@ -): ProjectGroupOwnerRecord | null { +): T | null { @@ -317,2 +318,3 @@ - return resolution?.kind === 'resolved' ? resolution.owner : null -} + // The cache is keyed by this exact array, so its owner retains the caller's row type. + return resolution?.kind === 'resolved' ? (resolution.owner as T) : null +} --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -98 +98 @@ -export async function ensureRuntimeEnvironmentCompatible( +async function ensureRuntimeEnvironmentCompatible( --- a/src/renderer/src/store/slices/terminal-tab-retirement.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts @@ -136,24 +135,0 @@ -} - -export function hasTerminalPtyOwnerOutsidePane( - state: TerminalTabRetirementState, - identity: string, - tabId: string, - excludedLeafId?: string -): boolean { - for (const [ownerTabId, owner] of collectLiveTerminalTabs(state)) { - const ids = - ownerTabId === tabId - ? Object.entries(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}) - .filter(([leafId]) => leafId !== excludedLeafId) - .map(([, ptyId]) => ptyId) - : collectPtyIdsForTab(state, ownerTabId, owner.rowPtyId) - if ( - ids.some( - (ptyId) => getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId) === identity - ) - ) { - return true - } - } - return false --- a/src/renderer/src/store/terminals/terminal-tab-close-empty.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-empty.ts @@ -1,42 +0,0 @@ -import { captureEmptyTerminalTabRetirement } from '../../../../shared/empty-terminal-tab-retirement' -import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' -import type { AppState } from '../types' -import type { TerminalTabRetirementPlan } from '../slices/terminal-tab-retirement' - -export function requestEmptyTerminalTabRetirement( - state: AppState, - tabId: string, - plan: TerminalTabRetirementPlan, - reason: string -): void { - if ( - typeof window === 'undefined' || - reason !== 'user' || - plan.ptyIds.length > 0 || - !plan.worktreeId || - getConnectionIdFromState(state, plan.worktreeId) !== null || - getExecutionHostIdForWorktree(state, plan.worktreeId) !== 'local' - ) { - return - } - if ( - Object.values(state.unifiedTabsByWorktree).some((tabs) => - tabs.some( - (row) => - (row.id === tabId || row.entityId === tabId) && - (row.structuredSessionId || row.viewMode === 'chat') - ) - ) - ) { - return - } - const request = captureEmptyTerminalTabRetirement(state, plan.worktreeId, tabId) - const retire = window.api.session?.retireEmptyTerminalTab - if (!request || !retire) { - return - } - void retire(request).catch((error: unknown) => - console.warn('Empty terminal tab retirement failed', error) - ) -} --- a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts @@ -1,4 +0,0 @@ -import { - captureRuntimeEnvironmentRequestRevision, - getRuntimeEnvironmentRevision -} from '@/runtime/runtime-environment-revision' @@ -6 +2 @@ -import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -18,2 +14 @@ - tabId, - canRetireRuntimeTerminal + tabId @@ -26 +20,0 @@ - canRetireRuntimeTerminal?: () => boolean @@ -42 +36,5 @@ - retireRuntimeTerminal(environmentId, terminal.handle, canRetireRuntimeTerminal) + callRuntimeRpc( + environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }, + 'terminal.close', + { terminal: terminal.handle } + ) @@ -71,37 +68,0 @@ - -async function retireRuntimeTerminal( - environmentId: string | null | undefined, - handle: string, - canRetire?: () => boolean -): Promise { - const target = environmentId - ? { kind: 'environment' as const, environmentId } - : { kind: 'local' as const } - if (!canRetire) { - return callRuntimeRpc(target, 'terminal.close', { terminal: handle }) - } - const revision = environmentId - ? captureRuntimeEnvironmentRequestRevision(environmentId) - : undefined - if (environmentId) { - await ensureRuntimeEnvironmentCompatible(environmentId, { - expectedEnvironmentPairingRevision: revision - }) - } - if ( - (environmentId && getRuntimeEnvironmentRevision(environmentId) !== revision) || - !canRetire() - ) { - return - } - // Compatibility was checked above; recheck pane ownership at the actual dispatch boundary. - return callRuntimeRpc( - target, - 'terminal.close', - { terminal: handle }, - { - skipCompatibilityCheck: true, - expectedEnvironmentPairingRevision: revision - } - ) -} --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -1 +0,0 @@ -import { requestEmptyTerminalTabRetirement } from './terminal-tab-close-empty' @@ -35 +33,0 @@ - requestEmptyTerminalTabRetirement(get(), tabId, retirementPlan, closeReason) --- a/src/shared/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -6 +5,0 @@ -import { ownRetainedString } from './own-retained-string' @@ -61 +60 @@ -function buildCheckLogTail(logText: string): string { +export function sliceCheckLogTail(logText: string): string { @@ -84,5 +82,0 @@ - -export function sliceCheckLogTail(logText: string): string { - // Cached excerpts must not pin the downloaded log behind a small V8 slice. - return ownRetainedString(buildCheckLogTail(logText)) -} --- a/src/shared/empty-terminal-tab-retirement.ts +++ b/src/shared/empty-terminal-tab-retirement.ts @@ -1,96 +0,0 @@ -import type { WorkspaceSessionState } from './workspace-session-state-types' - -export type EmptyTerminalTabRetirementRequest = { - worktreeId: string - tabId: string - createdAt: number - generation: number -} -export type EmptyTerminalTabRetirementResult = - | { closed: true } - | { - closed: false - reason: - | 'invalid-identity' - | 'not-local' - | 'renderer-owned-membership' - | 'structured-owner' - | 'different-owner' - | 'stale-terminal' - | 'unrepresented-owner' - | 'runtime-owner' - | 'runtime-unavailable' - } - -type EmptyTerminalTabState = Pick< - WorkspaceSessionState, - | 'tabsByWorktree' - | 'terminalLayoutsByTabId' - | 'remoteSessionIdsByTabId' - | 'terminalPtyIncarnationsByPaneKey' - | 'sleepingAgentSessionsByPaneKey' -> - -export function hasTerminalTabBindingOrHistory( - session: EmptyTerminalTabState, - tabId: string -): boolean { - const layout = session.terminalLayoutsByTabId[tabId] - return Boolean( - layout?.root || - layout?.activeLeafId || - layout?.expandedLeafId || - session.remoteSessionIdsByTabId?.[tabId] || - [layout?.ptyIdsByLeafId, layout?.buffersByLeafId, layout?.scrollbackRefsByLeafId].some( - (map) => Object.keys(map ?? {}).length > 0 - ) || - Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).some((key) => - key.startsWith(`${tabId}:`) - ) || - Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).some( - ([key, value]) => key.startsWith(`${tabId}:`) || value.tabId === tabId - ) - ) -} - -export function captureEmptyTerminalTabRetirement( - session: EmptyTerminalTabState, - worktreeId: string, - tabId: string -): EmptyTerminalTabRetirementRequest | undefined { - const tabs = Object.values(session.tabsByWorktree) - .flat() - .filter((tab) => tab.id === tabId) - const row = session.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId) - if ( - !row || - tabs.length !== 1 || - row.worktreeId !== worktreeId || - row.ptyId || - row.isPinned || - row.viewMode === 'chat' || - (row.generation ?? 0) !== 0 || - hasTerminalTabBindingOrHistory(session, tabId) - ) { - return undefined - } - return { worktreeId, tabId, createdAt: row.createdAt, generation: 0 } -} - -export function isEmptyTerminalTabRetirementRequest( - value: unknown -): value is EmptyTerminalTabRetirementRequest { - return ( - typeof value === 'object' && - value !== null && - 'worktreeId' in value && - typeof value.worktreeId === 'string' && - 'tabId' in value && - typeof value.tabId === 'string' && - 'createdAt' in value && - typeof value.createdAt === 'number' && - Number.isFinite(value.createdAt) && - 'generation' in value && - value.generation === 0 - ) -} --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -132,0 +133,5 @@ +// Why: an older host strips terminal.create's unknown `shell` and answers with a terminal running +// the host default shell. That reply is indistinguishable from success, so a client asking for a +// shell must refuse rather than create the wrong one. +export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY = + 'terminal.create-shell-selection.v1' as const @@ -232,0 +238,16 @@ +/** + * `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the + * surface, instead of each client routing for itself. + * + * Negotiated rather than assumed because a client that cannot see it must keep using + * `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the + * dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was + * requested, so a host that quietly routed that call to a structured session would hand an old + * client a response with no handle and no error. + * + * Advertising it is a statement that the client understands EITHER outcome, since the host is what + * picks: a structured session it can open, or a terminal agent. A client that renders only one of + * the two keeps using the surface-specific methods. + */ +export const AGENT_LAUNCH_RUNTIME_CAPABILITY = 'agent.launch.v1' as const + @@ -242 +263,2 @@ - AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY + AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY @@ -298,0 +321 @@ + TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY, @@ -336 +359,2 @@ - NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY + NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -8 +8,2 @@ - AgentJournalToolCallItem + AgentJournalToolCallItem, + AgentJournalTurnLifecycle @@ -21,0 +23,21 @@ +} + +/** The same verdict for reduced items a caller holds unordered, so a reader that already has them + * need not render and sort a whole snapshot to ask. Sequence is the ordering key the render pass + * sorts on, and ties resolve to the later-reduced item exactly as that stable sort would. */ +export function activeStructuredAgentSessionTurnIdBySequence( + items: Iterable +): string | null { + let newestSequence = 0 + let newest: AgentJournalTurnLifecycle | null = null + for (const item of items) { + if (item.sequence < newestSequence) { + continue + } + const turn = readAgentJournalTurn(item.body) + if (turn) { + newestSequence = item.sequence + newest = turn + } + } + return newest?.state === 'running' ? newest.turnId : null --- a/src/shared/tui-agent-launch-defaults.ts +++ b/src/shared/tui-agent-launch-defaults.ts @@ -23,0 +24,15 @@ +} + +/** + * Whether the configured arguments carry this agent's permission-bypass flag. + * + * The Agent Permissions toggle has no storage of its own — it writes and reads this flag inside + * the arguments string — so presence at a token boundary, not whole-string equality, is what + * "Yolo" means. A terminal launch applies the flag wherever else the user has written in the field. + */ +export function tuiAgentArgsBypassPermissions( + agent: TuiAgent, + value: string | null | undefined +): boolean { + const bypassArg = YOLO_TUI_AGENT_ARGS[agent] + return typeof value === 'string' && bypassArg !== undefined && argPattern(bypassArg).test(value) @@ -95,0 +111,14 @@ +/** + * Whether this agent's *resolved* launch arguments ask for a permission bypass. + * + * Resolved, not configured: an untouched Arguments field falls back to the default Orca ships, + * which is the bypass flag, so bypass is the posture a user gets until they choose otherwise. + * Choosing Manual stores an empty string, which owns the key and so beats that default. + */ +export function resolvedTuiAgentArgsBypassPermissions( + agent: TuiAgent, + configuredArgs: Partial> | null | undefined +): boolean { + return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, configuredArgs)) +} + --- a/src/shared/windows-terminal-shell.ts +++ b/src/shared/windows-terminal-shell.ts @@ -56,0 +57,44 @@ + +/** + * Shell names a caller may request for a single Windows terminal, keyed by accepted spelling and + * mapped to the one canonical spelling every downstream consumer keys on. + * + * The relay owns the spawn and has always refused anything outside this set, but the set lived + * only there — so a bad value from `terminal create --shell` surfaced as a spawn-time throw with + * no way for the CLI to answer before the round trip. Shared so the RPC boundary and the relay + * agree on the same names. + * + * Why canonicalize: `resolveWindowsShellStartupFamily`, the launch-arg builders, and the Git Bash + * path resolver all exact-match the `.exe` spelling, so a bare `cmd` accepted here would spawn cmd + * yet quote its startup command for PowerShell. `pwsh` and `powershell` are different binaries and + * are never collapsed into each other. + */ +const WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES: ReadonlyMap = new Map([ + ['powershell.exe', 'powershell.exe'], + ['powershell', 'powershell.exe'], + ['pwsh.exe', 'pwsh.exe'], + ['pwsh', 'pwsh.exe'], + ['cmd.exe', 'cmd.exe'], + ['cmd', 'cmd.exe'], + ['wsl.exe', 'wsl.exe'], + ['wsl', 'wsl.exe'], + // Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay + // the one host that hard-failed a setting the local and daemon PTYs accept. + ['bash.exe', 'bash.exe'], + ['bash', 'bash.exe'], + [WINDOWS_GIT_BASH_SHELL, WINDOWS_GIT_BASH_SHELL] +]) + +/** Canonical spelling for an accepted override (case-insensitive), or undefined when refused. */ +export function canonicalizeWindowsShellOverride(shell: string): string | undefined { + return WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.get(shell.toLowerCase()) +} + +export function isSupportedWindowsShellOverride(shell: string): boolean { + return canonicalizeWindowsShellOverride(shell) !== undefined +} + +/** Sorted for a stable error message; callers list these when refusing a value. */ +export function listSupportedWindowsShellOverrides(): string[] { + return [...WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.keys()].sort() +}