mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* fix: retire closed editor models from the app shell * test(editor): use checked Monaco attachment calls * Preserve bounded editor view caches when retiring closed models * docs(editor): describe batched model retirement * fix(editor): preserve cleanup work across registry replacement * fix(editor): build editor model URIs with the file scheme Monaco keys its model registry by `uri.toString()`, and both `@monaco-editor/react` (via the `path` prop) and the closed-tab disposal path built that key with `Uri.parse`. On Windows a raw path such as `C:\repo\a.ts` parses as scheme `c`, which fails the scheme gate in `modelService._schemaShouldMaintainUndoRedoElements`, so closed-file undo history was dropped for every file at any size — not only the large files the tradeoff note covers. Add `toEditorModelUri`, the one filesystem-path -> model-key function, built on `Uri.file` so the result always carries the `file:` scheme and re-parses to itself. Route model creation, disposal lookup and the still-open ownership comparison through it so all three agree; a divergence there would dispose a model an open editor is still editing. --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai>
663 lines
26 KiB
Diff
663 lines
26 KiB
Diff
--- 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<T extends FolderWorkspaceOwnerRecord>(
|
|
+ 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<T extends ProjectGroupOwnerRecord>(
|
|
+ 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<unknown> {
|
|
- 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<AgentJournalRenderItem>
|
|
+): 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<Record<TuiAgent, string>> | 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<string, string> = 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()
|
|
+}
|