/* eslint-disable max-lines -- Why: OrcaRuntimeService still owns the mutable live graph, PTY handles, waiters, mobile floor/layout state, and managed-worktree reconciliation. Stateless browser and file command adapters live beside it; the remaining split points need state-owner extraction before enforcing max-lines. */ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { detectAgentStatusFromTitle, isClaudeManagementTitle, isCursorAgentTitle, isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalOscColorQueryReplyColors } from '../../shared/terminal-osc-color-reply' import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range' import type { RemoteTerminalSourceRangeConsumerHooks, RemoteTerminalSourceRangeReplacementPublication, RemoteTerminalSourceRangeReplacementReservation, RemoteTerminalSourceRangeStreamIdentity } from './remote-terminal-source-range-consumer' import { createTerminalTitleTracker, stripBrailleSpinnerGlyphs, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' import { AGENT_STATUS_STALE_AFTER_MS, isFreshNonDoneAgentStatus, pickParsedAgentStatusPayload, type AgentStatusIpcPayload, type ParsedAgentStatusPayload, type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, AgentSessionSurfaceBinding, AgentLaunchPreferences, RuntimeAgentSessionRpcCaller, RuntimeCreateAgentSessionRequest, RuntimeCreateAgentSessionResult, RuntimeEnsureAgentSessionRequest, RuntimeEnsureAgentSessionResult } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, parseAgentSessionOperationTimestamp } from '../../shared/agent-session-host-authority' import { canonicalizeAgentSessionIdentity, createEphemeralAgentSessionClaimSigner, type AgentSessionClaimSigner } from './agent-session-claim-identity' import { hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner' import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk } from '../../shared/agent-status-osc' import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display' import { isTerminalInputTooLargeWithYield, TERMINAL_INPUT_TOO_LARGE_ERROR, iterateTerminalInputChunks } from '../../shared/terminal-input' import { AGENT_PROMPT_BRACKETED_PASTE_END, AGENT_PROMPT_SUBMIT, AGENT_PROMPT_SUBMIT_DELAY_MS, buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' import { gitExecFileAsync, gitSpawn, nonInteractiveGitEnv } from '../git/runner' import { runWithGitReadCacheInvalidation } from '../git/status' import { cleanupClaimedCloneTarget, claimCloneTarget, deriveValidatedClonePath, getClonePathComparisonKey } from '../git/repo-clone-path' import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' import { OrchestrationError } from './orchestration/orchestration-error' import { planLegacyWorkerTerminalRecovery, type LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import { buildObservedSetupCommand, createSetupCompletionScanner } from './orchestration/setup-completion-signal' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' import type { OrchestrationCompatibilityEvidence, OrchestrationCompatibilityHostStamp } from '../../shared/orchestration-compatibility-evidence' import { isOrchestrationMutation, orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' import type { OrchestrationEnvironmentTransport, OrchestrationWorkerServer } from './orchestration/environment-transport' import { syncFederatedDispatch } from './orchestration/federation-sync' import { formatMessagesForInjection } from './orchestration/formatter' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' import type { Automation, AutomationCreateInput, AutomationRun, AutomationUpdateInput, AutomationWorkspaceMode } from '../../shared/automations-types' import type { AutomationWorkspaceProvenance, CliWorkspaceProvenance, BaseRefSearchResult, CreateWorktreeResult, DetectedWorktree, DetectedWorktreeListResult, ForceDeleteWorktreeBranchResult, GitHubPrStartPoint, GitPushTarget, BranchPrefixStrategy, GitWorktreeInfo, GitHubCreateIssueFields, GitHubOwnerRepo, GlobalSettings, PersistedUIState, Project, ProjectUpdateArgs, ProjectHostSetup, ProjectHostSetupCloneArgs, ProjectHostSetupCreateArgs, ProjectHostSetupCreateResult, ProjectHostSetupDeleteArgs, ProjectHostSetupDeleteResult, ProjectHostSetupExistingFolderArgs, ProjectHostSetupResult, ProjectHostSetupUpdateArgs, ProjectHostSetupUpdateResult, Repo, RemoveWorktreeResult, StatsSummary, Worktree, WorktreeLineage, WorkspaceLineage, WorkspaceKey, WorktreeLineageWarning, WorktreeMeta, WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent, WorktreeStartupLaunch, LinearCustomViewModel, JiraConnectArgs, JiraCreateIssueArgs, JiraIssueFilter, JiraIssueUpdate, JiraSiteSelection, LinearIssueUpdate, LinearProjectSummary, LinearWorkspaceSelection, NestedRepoScanResult, ProjectGroup, FolderWorkspace, ProjectGroupImportMode, ProjectGroupImportResult, MemorySnapshot, Tab, TabGroupLayoutNode, TerminalQuickCommand, TerminalLayoutSnapshot, TerminalPaneLayoutNode, TerminalTab, TuiAgent, WorkspaceCreateTelemetrySource, WorkspaceSessionState, WorkspaceLinkedItem, DirEntry, FilesystemPathFlavor, GitHubIssueUpdate, GitHubPullRequestStateUpdate, GitHubPRFile, GitHubPRReviewCommentInput, GitLabIssueUpdate, GitLabMRInlineCommentInput, GitLabProjectRef, GitLabWorkItem, ListWorkItemsResult, MRListState, PRRefreshOutcome, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' import type { TaskSourceContext } from '../../shared/task-source-context' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { LOCAL_EXECUTION_HOST_ID, getRepoExecutionHostId, parseExecutionHostId, toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { getRegisteredSshState } from '../ipc/ssh' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ExactWorkerProviderSession } from '../../shared/orchestration-worker-output' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import { navigationTargetsClients, navigationTargetsHost, type RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { isAutomaticTabActivation, type TabActivationIntent } from '../../shared/tab-activation-intent' import type { SshConnectionState } from '../../shared/ssh-types' import { getPublicSshState } from './public-ssh-state' import { closeTerminalTabInWorkspaceSession } from '../../shared/workspace-session-terminal-tab-close' import type { LinearCurrentIssueContextHints, LinearAttachResult, LinearCommentAddResult, LinearCreateResult, LinearErrorCode, LinearIssueListFilter, LinearIssueListResult, LinearProjectListResult, LinearIssueSummary, LinearIssueRequest, LinearIssueTaskUpdateRequest, LinearIssueTaskUpdateResult, LinearMcpIssueListRequest, LinearMcpIssueListResult, LinearIssueRelationWriteRequest, LinearIssueRelationWriteResult, LinearSaveIssueRequest, LinearSaveIssueResult, LinearTeamLabelsResult, LinearTeamListResult, LinearTeamMembersResult, LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear-agent-access' import { HEADLESS_RUNTIME_WINDOW_ID, 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 RuntimeNativeChatLaunchDraftResolution, type RuntimeSessionTabCloseReason, type RuntimeBrowserDriverState, type RuntimeTerminalDriverState, type RuntimeSyncWindowGraph, type RuntimeWorktreeListResult, type BrowserTabInfo, type BrowserScreencastResult } from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, clampLinearSearchLimit } from '../../shared/linear-agent-access' import { isLinearUuid } from '../../shared/linear-uuid' import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, WORKTREE_ID_SEPARATOR, getRepoIdFromWorktreeId, splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { getProjectIdForProviderIdentity, getProjectHostSetupForRepo, getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { parsePtySessionId } from '../../shared/pty-session-id-format' import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { buildSetupRunnerCommand, getSetupRunnerCommandPlatformForPath } from '../../shared/setup-runner-command' import { createSequencedSetupAgentCommands, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing' import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' import { isWslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { applyTerminalQuickCommandMutation, MAX_QUICK_COMMANDS, type TerminalQuickCommandMutation } from '../../shared/terminal-quick-commands' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { repoIsRemote } from '../../shared/agent-launch-remote' import { isAgentForegroundWrapperProcess, isExpectedAgentProcess, recognizeAgentProcess } from '../../shared/agent-process-recognition' import { haveSameDisabledTuiAgents, isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selection' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { getTuiAgentLaunchCommand, isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' import { detectInstalledAgentsWithShellPathHydration, detectRemoteAgents } from '../ipc/preflight' import { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' import { markRemoteAgentWorkspaceTrusted } from '../remote-agent-trust-presets' import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { isWindowsAbsolutePathLike, isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { findRuntimeWorkspaceFileOwner } from '../../shared/runtime-workspace-file-owner' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' import { projectResolvedWorktreeLineage, sharesResolvedWorktreeLineageBoundary } from '../../shared/resolved-worktree-lineage' import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' import { applyMetadataFallbackVisibility, buildKnownOrcaWorkspaceLayouts, isLegacyRepoForExternalWorktreeVisibility, toDetectedWorktree } from '../../shared/worktree-ownership' import { createAgentScratchWorktreePathMatcher, isAgentScratchRepoRootPath, type AgentScratchWorktreePathMatcher } from '../../shared/agent-scratch-worktrees' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, ORCHESTRATION_CONTRACT_VERSION, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../shared/protocol-version' import { configureAiVaultSessionSources, listAiVaultSessions } from '../ai-vault/cached-session-list' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' import type { WorkspacePortKillRequest, WorkspacePortKillResult, WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' import { filterWorkspacePortProbes, killWorkspacePort, scanWorkspacePortProbes } from '../ports/workspace-port-ownership' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { buildHeadlessTerminalSplitLayout, countTerminalLayoutLeaves } from './headless-terminal-split-layout' import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit } from './headless-tab-group-split-layout' import { hasExactTerminalOrphanGroupLayout, mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { terminalOrphanExecutionOwnersEqual } from './terminal-orphan-owner' import { retireTerminalSurfacesFromSnapshot, type RetiredTerminalSurface } from './mobile-session-terminal-retirement' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import { advanceTerminalTopologyRevision, hasHostAuthoritativeTerminalMembership } from './workspace-session-terminal-membership-authority' import { RuntimeEmulatorCommands } from './orca-runtime-emulator' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { getRuntimeFileTargetExecutionHostId, RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' import { activateClientSessionTabSelection, ClientSessionTabSelectionStore, deriveClientSessionTabSelection, projectClientSessionTabSelection } from './client-session-tab-selection' import type { PtyProviderBufferSnapshot, IFilesystemProvider, IPtyProvider, PtyProcessInfo, PtySpawnResult, PtyTransientFact } from '../providers/types' import { ClaudeAgentTeamsService } from './claude-agent-teams-service' import type { AgentTeamsTmuxCompatRequest, AgentTeamsTmuxCompatResponse } from './claude-agent-teams-service' import { buildClaudeAgentTeamsLaunchPlan, ensureClaudeAgentTeamsShimDir, resolveClaudeAgentTeamsShimBin } from './claude-agent-teams-shim-env' import { addClaudeTeammateModeAuto, addClaudeTeammateModeInProcess, type ClaudeAgentTeamsMode } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' import { app, BrowserWindow, ipcMain, Notification } from 'electron' import { RendererPublicationThrottle } from '../window/renderer-publication-throttle' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import { BrowserError } from '../browser/cdp-bridge' import { getPRForBranch, getPRForBranchOutcome, getRepoSlug, getRepoUpstream, getWorkItem, listIssues as listGitHubIssues, listWorkItems, countWorkItems, getPRChecks, getPRCheckDetails, rerunPRChecks, getPRComments, getIssue, resolveReviewThread, setPRFileViewed, getWorkItemByOwnerRepo, updatePRTitle, updatePRDetails, mergePR, setPRAutoMerge, updatePRState, requestPRReviewers, removePRReviewers, createIssue, updateIssue, addIssueComment, addPRReviewComment, addPRReviewCommentReply, listLabels, listAssignableUsers, type MainWorkItem, type GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { fetchGitHubPullRequestHeadRef, fetchPrHeadTrackingRef } from '../github/pr-head-tracking-ref' import { gitlabMergeRequestHeadLocalRef, reviewHeadRemoteRefComponent } from '../../shared/review-head-tracking-ref' import { fetchGitLabMergeRequestHeadRef } from '../gitlab/mr-head-tracking-ref' import { isTransientReviewHeadFetchError } from '../git/fetch-error-classification' import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch' import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' import { getRateLimit } from '../github/rate-limit' import { closeMR as closeGitLabMR, createIssue as createGitLabIssue, diagnoseAuth as diagnoseGitLabAuthClient, getJobTrace as getGitLabJobTrace, getProjectRefForRemote as getGitLabProjectRefForRemote, getRateLimit as getGitLabRateLimit, getWorkItemByProjectRef as getGitLabWorkItemByProjectRef, addIssueComment as addGitLabIssueComment, addMRInlineComment as addGitLabMRInlineComment, addMRComment as addGitLabMRComment, listTodos as listGitLabTodos, listIssues as listGitLabIssues, listLabels as listGitLabLabels, listMergeRequests as listGitLabMergeRequests, listWorkItems as listGitLabWorkItems, mergeMR as mergeGitLabMR, reopenMR as reopenGitLabMR, resolveMRDiscussion as resolveGitLabMRDiscussion, retryJob as retryGitLabJob, updateMR as updateGitLabMR, updateMRReviewers as updateGitLabMRReviewers, updateIssue as updateGitLabIssue } from '../gitlab/client' import { getGlabKnownHosts } from '../gitlab/gl-utils' import { getWorkItemDetails as getGitLabWorkItemDetails } from '../gitlab/work-item-details' import { normalizeGitLabIssueListArgs, normalizeGitLabMRListState, normalizeGitLabPositiveInteger, type GitLabIssueListState } from '../gitlab/gitlab-preload-args' import { recordGitLabProjectRecent } from '../gitlab/gitlab-project-recents' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { CreateHostedReviewInput, CreateHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../shared/hosted-review' import { getHostedReviewForBranch as getHostedReviewForBranchFromRepo } from '../source-control/hosted-review' import type { ForgeProviderId } from '../source-control/forge-provider' import { createHostedReview as createHostedReviewFromRepo, getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo } from '../source-control/hosted-review-creation' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, getLocalProjectWorktreeGitOptionsForRuntime, resolveLocalProjectRuntimeForRepo, resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command' import { getLocalWorktreePathAccess, removeLocalWorktreePath, toLocalWorktreeRuntimePath } from '../local-worktree-filesystem' import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, recoverLocalWindowsWorktreeRemoval } from '../local-worktree-removal-recovery' import { connect as connectLinear, disconnect as disconnectLinear, getStatus as getLinearStatus, isAuthError as isLinearAuthError, selectWorkspace as selectLinearWorkspace, testConnection as testLinearConnection } from '../linear/client' import { addIssueComment as addLinearIssueComment, addIssueCommentForAgent as addLinearIssueCommentForAgent, createIssueAttachment as createLinearIssueAttachment, createIssueForAgent as createLinearIssueForAgent, createIssue as createLinearIssue, getAttachmentByUuidForAgent as getLinearAttachmentByUuidForAgent, getCommentByUuidForAgent as getLinearCommentByUuidForAgent, getIssue as getLinearIssue, getIssueByUuidForAgent as getLinearIssueByUuidForAgent, getIssueCommentThreadRoot as getLinearIssueCommentThreadRoot, getIssueComments as getLinearIssueComments, listIssues as listLinearIssues, searchIssues as searchLinearIssues, updateIssueForAgent as updateLinearIssueForAgent, updateIssue as updateLinearIssue, LinearWriteFailure, type LinearListFilter, type LinearIssueListOptions } from '../linear/issues' import { LinearAgentAccessError, getLinearCurrentIssueFromWorktree, readLinearIssueContext, resolveLegacyLinearLinkWorkspace, searchLinearIssuesForAgents } from '../linear/issue-context' import { classifyLinearError, linearError, linearMessage, sanitizeLinearErrorMessage } from '../linear/issue-context-errors' import { listMcpIssues } from '../linear/mcp-issue-list' import { writeIssueRelation } from '../linear/issue-relation-write' import { createProject as createLinearProject, getCustomView as getLinearCustomView, getProject as getLinearProject, listCustomViewIssues as listLinearCustomViewIssues, listCustomViewProjects as listLinearCustomViewProjects, listCustomViews as listLinearCustomViews, listProjectsByExactName as listLinearProjectsByExactName, listProjectIssues as listLinearProjectIssues, listProjectTeams as listLinearProjectTeams, listProjects as listLinearProjects, type LinearProjectCreateInput } from '../linear/projects' import { getTeamLabels as getLinearTeamLabels, getTeamLabelsOrThrow as getLinearTeamLabelsOrThrow, getTeamMembers as getLinearTeamMembers, getTeamMembersOrThrow as getLinearTeamMembersOrThrow, getTeamStates as getLinearTeamStates, getTeamStatesOrThrow as getLinearTeamStatesOrThrow, getViewerForWorkspaceOrThrow as getLinearViewerForWorkspaceOrThrow, listTeamsForAgent as listLinearTeamsForAgent, listTeams as listLinearTeams, listTeamsOrThrow as listLinearTeamsOrThrow } from '../linear/teams' import { connect as connectJira, disconnect as disconnectJira, getStatus as getJiraStatus, selectSite as selectJiraSite, testConnection as testJiraConnection } from '../jira/client' import { addIssueComment as addJiraIssueComment, createIssue as createJiraIssue, getIssue as getJiraIssue, getIssueSummary as getJiraIssueSummary, getIssueComments as getJiraIssueComments, getProjectStatusOrder as getJiraProjectStatusOrder, listAssignableUsers as listJiraAssignableUsers, listCreateFields as listJiraCreateFields, listIssueTypes as listJiraIssueTypes, listIssues as listJiraIssues, listPriorities as listJiraPriorities, listProjects as listJiraProjects, listTransitions as listJiraTransitions, searchIssues as searchJiraIssues, updateIssue as updateJiraIssue } from '../jira/issues' import { clearProjectItemFieldValue, getProjectViewTable, getWorkItemDetailsBySlug, listAccessibleProjects, listProjectViews, resolveProjectRef, addIssueCommentBySlug, deleteIssueCommentBySlug, listAssignableUsersBySlug, listIssueTypesBySlug, listLabelsBySlug, updateIssueCommentBySlug, updateIssueBySlug, updateIssueTypeBySlug, updateProjectItemFieldValue, updatePullRequestBySlug } from '../github/project-view' import type { ClearProjectItemFieldArgs, GetProjectViewTableArgs, ListAccessibleProjectsArgs, ListAssignableUsersBySlugArgs, ListIssueTypesBySlugArgs, ListLabelsBySlugArgs, ListProjectViewsArgs, ProjectWorkItemDetailsBySlugArgs, ResolveProjectRefArgs, AddIssueCommentBySlugArgs, DeleteIssueCommentBySlugArgs, UpdateIssueBySlugArgs, UpdateIssueCommentBySlugArgs, UpdateIssueTypeBySlugArgs, UpdateProjectItemFieldArgs, UpdatePullRequestBySlugArgs } from '../../shared/github-project-types' import { getBaseRefDefault, getDefaultRemote, getBranchConflictKind, isGitRepo, getRepoName, searchBaseRefDetails, getRemoteCount, normalizeRefSearchQuery, parseAndFilterSearchRefDetails, parseRemoteCount, resolveDefaultBaseRefViaExec, resolveDefaultBaseRefWithLocalGit, buildSearchBaseRefsArgv, isForEachRefExcludeUnsupportedError, mergeBaseRefSearchResultGroups, getRemoteDrift, getRecentDriftSubjects } from '../git/repo' import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' import { resolveLocalGitUsername } from '../git/git-username' import { getSshGitCapabilityCache } from '../git/git-capability-state' import { listWorktrees, listWorktreesStrict, addWorktree, addSparseWorktree, assertWorktreeCleanForRemoval, forceDeleteLocalBranch, removeWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' import { isENOENT, invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getDefaultTabCommandTrustContent, getDefaultTabsLaunch, getEffectiveHooks, getEffectiveSetupRunPolicy, hasUnrecognizedOrcaYamlKeys, hasHooksFile, loadHooks, parseOrcaYaml, readIssueCommand, resolveSetupRunnerShell, runHook, shouldRunSetupForCreate, writeIssueCommand } from '../hooks' import { DEFAULT_REPO_BADGE_COLOR, FLOATING_TERMINAL_WORKTREE_ID, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeCopiedPaths, createWorktreeLinkedPaths, createWorktreeSharedPaths, findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../ipc/worktree-symlinks' import { formatWorktreeIncludeCopyWarning } from '../ipc/worktree-include-copy-budget' import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' import { getWorktreeSharedLinkPaths, resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' import { cleanupUnusedWorktreePushTargetRemote, cleanupUnusedWorktreePushTargetRemoteSsh, createRemoteWorktree, configureCreatedWorktreePushTarget, prepareWorktreePushTarget } from '../ipc/worktree-remote' import { getBranchNameOverrideCandidate, getWorktreeCreateCandidate, WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS } from '../worktree-create-candidates' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import { AgentDetector } from '../stats/agent-detector' import { computeValidatedBranchName, computeWorktreePath, computeWorkspaceRoot, ensurePathWithinWorkspace, formatWorktreeRemovalError, getWorktreeCreationLayout, getWorktreePathSettings, isOrphanCompatiblePreflightError, isOrphanedWorktreeError, mergeWorktree, sanitizeWorktreeName, shouldSetDisplayName, areWorktreePathsEqual } from '../ipc/worktree-logic' import { findCreatedWorktree } from '../ipc/created-worktree-reconciliation' import { worktreePathComparisonKey } from '../ipc/worktree-path-comparison' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaLeftoverDirectory, canCleanupUnregisteredOrcaWorktreeDirectory, canSafelyRemoveOrphanedWorktreeDirectory, findRegisteredDeletableWorktree, isDangerousWorktreeRemovalPath, isWorktreePathMissing, ORPHANED_WORKTREE_DIRECTORY_MESSAGE, stripOrcaProvenanceMetaUpdates, UNREGISTERED_MISSING_WORKTREE_MESSAGE } from '../worktree-removal-safety' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { closeLocalWatcherForWorktreePath, closeRemoteWatcherForWorktreePath, forgetLocalWatcherRemovalSnapshot, forgetRemoteWatcherRemovalSnapshot, restoreLocalWatcherAfterFailedRemoval, restoreRemoteWatcherAfterFailedRemoval } from '../ipc/filesystem-watcher' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' import { createWatcherRemovalDeadline, drainBeforeWatcherRemoval, type WatcherRemovalDeadline } from '../ipc/watcher-removal-drain' import { withWorktreeSpan } from '../observability/instrumentation' import { HeadlessEmulator } from '../daemon/headless-emulator' import { isNativeWindowsConptyPty, registerConptyDa1OverrideInstaller, shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority' import { getTerminalViewAttributes, getTerminalViewColorQueryReplyColors, registerTerminalViewAttributesApplier } from './terminal-view-attribute-store' import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown' import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation' import { MobileNotificationReplayBuffer, type ReplayableMobileNotification } from './mobile-notification-replay' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, getFolderWorkspacePathStatus, getFolderWorkspacePathStatusForPath, inferFolderWorkspacePathConnection } from '../project-groups/folder-workspace-path-status' import { getSshGitProvider, getSshGitProviderGeneration, requireSshGitProvider } from '../providers/ssh-git-dispatch' import { detectRepoIconAndUpstream } from '../repo-icon-autodetect' import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment' import { githubAvatarIcon } from '../../shared/repo-icon' import type { ClaudeAccountService } from '../claude-accounts/service' import type { CodexAccountService, CodexResetCreditRejectedBeforeProviderReason } from '../codex-accounts/service' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { RateLimitService } from '../rate-limits/service' 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' import type { VoiceSettings } from '../../shared/speech-types' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' import { getCatalogModel, isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog' import { deleteLocalSpeechModel, getSpeechModelDeletionErrorCode } from '../speech/speech-model-deletion' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { scanNestedRepos } from '../project-groups/nested-repo-discovery' import { createNestedProjectGroupResolver, resolveNestedRepoSelection } from '../project-groups/nested-repo-import' import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string { console.warn(`[project-groups] ${context}`, error) return 'Repository could not be imported' } type RuntimeAccountServices = { claudeAccounts: ClaudeAccountService codexAccounts: CodexAccountService rateLimits: RateLimitService } export type RemoteFetchResult = { ok: true } | { ok: false; errorKind: 'git_error' } export type RemoteTrackingBase = { remote: string branch: string ref: string base: string } export type AccountsSnapshot = { claude: ClaudeRateLimitAccountsState codex: CodexRateLimitAccountsState rateLimits: RateLimitState } export type CodexRateLimitResetRpcResult = { scope: CodexResetCreditExpectedScope snapshot: AccountsSnapshot } & ( | { outcome: CodexRateLimitResetOutcome } | { status: 'rejectedBeforeProvider' retryDisposition: 'discardAttempt' reason: CodexResetCreditRejectedBeforeProviderReason } ) type RuntimeStore = { getRepos: Store['getRepos'] getRepo: Store['getRepo'] addRepo: Store['addRepo'] updateRepo: Store['updateRepo'] getProjects?: Store['getProjects'] updateProject?: Store['updateProject'] getProjectHostSetups?: Store['getProjectHostSetups'] createProjectHostSetup?: Store['createProjectHostSetup'] updateProjectHostSetup?: Store['updateProjectHostSetup'] deleteProjectHostSetup?: Store['deleteProjectHostSetup'] getProjectGroups?: Store['getProjectGroups'] createProjectGroup?: Store['createProjectGroup'] updateProjectGroup?: Store['updateProjectGroup'] deleteProjectGroup?: Store['deleteProjectGroup'] moveProjectToGroup?: Store['moveProjectToGroup'] getFolderWorkspaces?: Store['getFolderWorkspaces'] createFolderWorkspace?: Store['createFolderWorkspace'] updateFolderWorkspace?: Store['updateFolderWorkspace'] removeFolderWorkspace?: Store['removeFolderWorkspace'] removeProject?: Store['removeProject'] removeProjectForHost?: Store['removeProjectForHost'] reorderRepos?: Store['reorderRepos'] getAllWorktreeMeta: Store['getAllWorktreeMeta'] getWorktreeMeta: Store['getWorktreeMeta'] setWorktreeMeta: Store['setWorktreeMeta'] removeWorktreeMeta: Store['removeWorktreeMeta'] getWorktreeLineage?: Store['getWorktreeLineage'] getAllWorktreeLineage?: Store['getAllWorktreeLineage'] setWorktreeLineage?: Store['setWorktreeLineage'] removeWorktreeLineage?: Store['removeWorktreeLineage'] getAllWorkspaceLineage?: Store['getAllWorkspaceLineage'] setWorkspaceLineage?: Store['setWorkspaceLineage'] removeWorkspaceLineage?: Store['removeWorkspaceLineage'] getGitHubCache: Store['getGitHubCache'] getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] setWorkspaceSession?: Store['setWorkspaceSession'] flushOrThrow?: Store['flushOrThrow'] persistPtyBinding?: Store['persistPtyBinding'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] updateUI?: Store['updateUI'] recordFeatureInteraction?: Store['recordFeatureInteraction'] listAutomations?: Store['listAutomations'] listAutomationRuns?: Store['listAutomationRuns'] createAutomation?: Store['createAutomation'] updateAutomation?: Store['updateAutomation'] deleteAutomation?: Store['deleteAutomation'] getSparsePresets?: Store['getSparsePresets'] saveSparsePreset?: Store['saveSparsePreset'] getMobileClientTabSelections?: Store['getMobileClientTabSelections'] setMobileClientTabSelections?: Store['setMobileClientTabSelections'] getSettings(): { workspaceDir: string nestWorkspaces: boolean refreshLocalBaseRefOnWorktreeCreate: boolean localBaseRefSuggestionDismissed?: boolean branchPrefix: string branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] disabledTuiAgents?: GlobalSettings['disabledTuiAgents'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] agentDefaultArgs?: GlobalSettings['agentDefaultArgs'] agentDefaultEnv?: GlobalSettings['agentDefaultEnv'] terminalWindowsShell?: GlobalSettings['terminalWindowsShell'] floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled'] agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] defaultRepoSelection?: GlobalSettings['defaultRepoSelection'] defaultLinearTeamSelection?: GlobalSettings['defaultLinearTeamSelection'] githubProjects?: GlobalSettings['githubProjects'] experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle'] compactWorktreeCards?: GlobalSettings['compactWorktreeCards'] minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides'] terminalQuickCommands?: GlobalSettings['terminalQuickCommands'] gitlabProjects?: GlobalSettings['gitlabProjects'] mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null voice?: VoiceSettings claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] // Why: Phase-5 query responder kill switches — read per chunk in // onPtyData to capture reply ownership at ingestion. terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority'] terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate'] terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value // is read back via getSettings() on the next access. updateSettings?: ( updates: Partial, options?: { notifyListeners?: boolean; originWebContentsId?: number } ) => unknown } export type RuntimeAutomationCreateInput = Omit< AutomationCreateInput, 'projectId' | 'workspaceId' | 'workspaceMode' | 'timezone' > & { repo?: string workspace?: string workspaceMode?: AutomationWorkspaceMode timezone?: string } export type RuntimeAutomationUpdateInput = Omit< AutomationUpdateInput, 'projectId' | 'workspaceId' > & { repo?: string workspace?: string } function normalizeSparsePresetName(name: string): string { const trimmed = name.trim() if (!trimmed) { throw new Error('Preset name is required.') } if (trimmed.length > 80) { throw new Error('Preset name is too long.') } return trimmed } function normalizeSparsePresetDirectoriesForSave(directories: string[]): string[] { let normalized: string[] try { normalized = normalizeSparseDirectories(directories) } catch (err) { if ( err instanceof Error && err.message === 'Sparse checkout directories must be repo-relative paths.' ) { throw new Error('Preset directories must be repo-relative paths.') } throw err } if (normalized.length === 0) { throw new Error('Preset must have at least one directory.') } return normalized } function hasRuntimeAutomationUpdateValue( updates: RuntimeAutomationUpdateInput, key: K ): boolean { return Object.hasOwn(updates, key) && updates[key] !== undefined } type RuntimeLeafRecord = RuntimeSyncedLeaf & { ptyGeneration: number connected: boolean writable: boolean lastOutputAt: number | null lastExitCode: number | null tailBuffer: string[] tailTranscriptBuffer: string[] tailTranscriptChars: number tailPartialLine: string tailPendingAnsi: string tailRedrawCursor: RetainedTailRedrawCursor | null tailTruncated: boolean tailLinesTotal: number preview: string waitBlockedAt: number | null // Why: memoized wait scan of the current retained tail so the next PTY chunk // reuses it as its "previous" state instead of rebuilding + rescanning the // full tail. See computeTerminalTailWaitState. tailWaitState?: TerminalTailWaitState lastAgentStatus: AgentStatus | null // Why: the most recent OSC title observed on this leaf's PTY data. Used by // worktree.ps so daemon-hosted terminals (no renderer pushing pane titles) // still recompute working/idle from the live title each call instead of // serving a stale `lastAgentStatus` after the agent process exits and the // shell takes over the title — the bug behind issue #1437. lastOscTitle: string | null lastOscTitleAt: number | null paneTitleUpdatedAt: number | null } function isCursorAgentOrchestrationTarget( leaf: RuntimeLeafRecord, tabTitle: string | null | undefined ): boolean { return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle) } type RuntimePtyWorktreeRecord = { ptyId: string incarnationId: PtyIncarnationId | null worktreeId: string connectionId: string | null runtimeSessionOwned: boolean // Why: a Windows host can own both native and WSL panes; preamble command // selection must follow the pane that executes it, not process.platform. isWsl: boolean | null wslDistro: string | null // Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the // spawn-time tab/pane identity so later reveals can adopt under the env key. tabId: string | null paneKey: string | null launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null launchAgent: TuiAgent | null foregroundAgent: TuiAgent | null connected: boolean disconnectedAt: number | null lastExitCode: number | null lastAgentStatus: AgentStatus | null lastOscTitle: string | null lastOscTitleAt: number | null // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, // comparable only to other title stamps. Anything that must date a live title // against an off-pane clock (hook `receivedAt`) needs wall-clock ms. lastOscTitleEpochMs: number | null managementTitle: string | null managementTitleAt: number | null controllerTitle: string | null title: string | null titleUpdatedAt: number | null lastOutputAt: number | null tailBuffer: string[] tailTranscriptBuffer: string[] tailTranscriptChars: number tailPartialLine: string tailPendingAnsi: string tailRedrawCursor: RetainedTailRedrawCursor | null tailTruncated: boolean tailLinesTotal: number preview: string waitBlockedAt: number | null // Why: memoized wait scan of the current retained tail (see RuntimeLeafRecord). tailWaitState?: TerminalTailWaitState } type TerminalCreateOptions = { command?: string claudeAgentTeamsSourceCommand?: string cwd?: string env?: Record envToDelete?: string[] launchConfig?: WorktreeStartupLaunch['launchConfig'] resumeProviderSession?: AgentProviderSessionMetadata launchToken?: string launchAgent?: TuiAgent // Why: agent ids are not shell commands (`cursor` is the Cursor desktop app; its // CLI is `cursor-agent`). Callers that know the agent name it here instead of // guessing a command, and the runtime builds the configured launch. startupAgent?: TuiAgent terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string focus?: boolean rendererBacked?: boolean activate?: boolean presentation?: RuntimeTerminalPresentation // Why: `false` adopts the terminal without pointing the user at it — no // sidebar reveal, no tab focus. Distinct from 'background' presentation, // which skips renderer adoption entirely. surfaceOwner?: false tabId?: string leafId?: string sessionId?: string isNewSession?: boolean preAllocatedHandle?: string persistHostSessionBinding?: boolean // Why: only the host-derived structured resume path may attach provider // identity; opaque terminal.create commands remain ordinary shells. agentSessionClaim?: AgentSessionExecutionClaim agentSessionCreateOperationId?: string signal?: AbortSignal // Why: idempotent create operations must retain their fence after the PTY // exists, even if later runtime publication fails. onPtySpawnCommitted?: () => void // Why: the headless mobile-session create publishes its own authoritative // snapshot (with the correct target group) right after spawn. Skip the // intermediate pty-backed publish so the new tab doesn't briefly flash in // the wrong (active) group before the corrected snapshot lands. deferMobileSessionPublish?: boolean } function mergeTerminalEnvDeletionKeys( first: readonly string[] | undefined, second: readonly string[] | undefined ): string[] | undefined { const merged = [...new Set([...(first ?? []), ...(second ?? [])])] return merged.length > 0 ? merged : undefined } type AgentSessionCreateOperation = { fingerprint: string promise: Promise } function isAgentSessionOperationOutcomeUnknown(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'agentSessionOperationOutcome' in error && error.agentSessionOperationOutcome === 'unknown' ) } const AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT = 512 const AGENT_SESSION_OPERATION_GLOBAL_LIMIT = 4_096 function deterministicAgentSessionUuid(seed: string): string { const hex = createHash('sha256').update(seed).digest('hex').slice(0, 32).split('') hex[12] = '4' hex[16] = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16) const value = hex.join('') return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}` } type PtyForegroundAgentRefresh = { promise: Promise startedAfterTitleObservation: number requestedAfterTitleObservation: number } function copySleepingAgentLaunchConfig( config: SleepingAgentLaunchConfig ): SleepingAgentLaunchConfig { return { ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), agentArgs: config.agentArgs, agentEnv: { ...config.agentEnv }, ...(config.ompResumeFilePath ? { ompResumeFilePath: config.ompResumeFilePath } : {}) } } function normalizeAgentLaunchCommandForMatch(command: string): string { return command.trim().replace(/\s+/g, ' ') } function resolveBareAgentLaunchCommand(args: { command: string | undefined settings: { agentCmdOverrides?: Partial> | null disabledTuiAgents?: Iterable | null } platform: NodeJS.Platform isRemote: boolean }): TuiAgent | null { const command = args.command ? normalizeAgentLaunchCommandForMatch(args.command) : '' if (!command) { return null } const cmdOverrides = args.settings.agentCmdOverrides ?? {} for (const agent of Object.keys(TUI_AGENT_CONFIG) as TuiAgent[]) { if (!isTuiAgentEnabled(agent, args.settings.disabledTuiAgents)) { continue } const override = cmdOverrides[agent]?.trim() const defaultLaunchCommand = getTuiAgentLaunchCommand(TUI_AGENT_CONFIG[agent], args.platform, { isRemote: args.isRemote }) const launchCommands = override ? [defaultLaunchCommand, override] : [defaultLaunchCommand] if ( launchCommands.some((candidate) => command === normalizeAgentLaunchCommandForMatch(candidate)) ) { return agent } } return null } function inferCapturedClaudeAgentTeamsMode( launchConfig: SleepingAgentLaunchConfig | undefined, command: string | undefined, currentMode: ClaudeAgentTeamsMode | undefined ): ClaudeAgentTeamsMode | undefined { const capturedCommand = launchConfig?.agentCommand?.trim() || command?.trim() || '' const capturedArgs = launchConfig?.agentArgs?.trim() ?? '' const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() if (/(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch)) { return 'native-panes-shim' } if (/(^|\s)--teammate-mode(?:=|\s+)in-process(?:\s|$)/.test(capturedLaunch)) { return 'in-process' } if (launchConfig && /(^|\s)--resume(?:\s|=|$)/.test(command?.trim() ?? '')) { return 'off' } return currentMode } export type RuntimeTerminalAgentStatusEvent = { ptyId: string source: 'mounted-leaf' | 'pty-record' paneKey: string tabId?: string worktreeId?: string connectionId?: string | null payload: ParsedAgentStatusPayload } type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker // Why: onPtyData batches the mobile session-tab touch to once per chunk; // the stale-working-title timer fires between chunks and must touch // immediately. These flags route the tracker callback to the right mode. applyingChunk: boolean // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the // synthetic path gates mobile snapshot fan-out on a non-decorative title // change (spinner glyph + status comparison key kept below). applyingSyntheticFrame: boolean lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean // Why: facts observed while applying a chunk are batched into one // pty:sideEffect emission per chunk, preserving status/title/bell order. // Timer-fired facts emit immediately between chunks. pendingFacts: TerminalSideEffectFact[] // Why: Command Code lacks hooks, so its working/done state is scraped from // TUI output. Null when no side-effect consumer exists (headless serve) — // the scrape produces facts only. commandCodeDetector: { observe: (data: string) => boolean } | null } // Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and // is then forwarded to the renderer and dropped. Mobile is served by the main // process and has no renderer store, so we retain the latest payload per pane // here to feed worktree.ps's inline agent rows (1:1 with the desktop sidebar). type RuntimeAgentRowSnapshot = { paneKey: string ptyId: string worktreeId?: string tabId?: string // Transport of the pane's PTY at retain time; null for local. Without it, // OSC rows for SSH panes would lose the remote exemption in worktree.ps. connectionId: string | null payload: ParsedAgentStatusPayload // When the current payload.state was first observed for this pane (ms). stateStartedAt: number updatedAt: number } /** A hook row narrowed to what `session.tabs` publishes, shaped like the retained OSC * snapshot so one projection branch can consume either carrier. */ type HookLiveAgentRow = Pick< RuntimeAgentRowSnapshot, 'payload' | 'updatedAt' | 'stateStartedAt' | 'worktreeId' > type RuntimeHeadlessTerminal = { emulator: HeadlessEmulator // Why: serialize can race with newer writes appended to writeChain; return // the seq actually painted into this emulator, not the latest PTY seq. outputSequence: number writeChain: Promise } export type RuntimePtyDataAdmission = Readonly<{ sequence: number completion: Promise }> export type RuntimeTerminalDataMeta = Readonly<{ seq?: number rawLength?: number transformed?: boolean cwd?: string sourceRanges?: readonly TerminalOutputSourceRange[] }> type RuntimeVisibleTerminalState = { lines: string[] isAlternateScreen: boolean sequence: number generation: number } type ProviderBufferAcquisition = { generation: number scrollbackRows: number promise: Promise timedOut: boolean } type RuntimeTerminalBufferSnapshot = { data: string cols: number rows: number seq?: number cwd?: string | null lastTitle?: string source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string } type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] /** Cold restore history must outrank a model that only saw new-generation bytes. */ preferProviderIfExisting?: boolean /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh * emulator so hidden `CSI ? u` answers the real flags instead of ?0u * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number } type RuntimePtyController = { claimStablePaneCreate?(args: { worktreeId: string connectionId: string | null tabId: string leafId: string }): () => void adoptStablePane?(opts: { cols: number rows: number cwd?: string connectionId: string | null worktreeId: string preAllocatedHandle: string tabId: string leafId: string }): Promise<{ result: PtySpawnResult owner: { handle?: string tabId: string leafId: string ptyId: string incarnationId?: string } materialized?: true } | null> spawn?(opts: { cols: number rows: number cwd?: string command?: string launchAgent?: TuiAgent commandDelivery?: 'renderer' | 'provider' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] env?: Record envToDelete?: string[] resumeProviderSession?: AgentProviderSessionMetadata telemetry?: WorktreeStartupLaunch['telemetry'] connectionId?: string | null worktreeId?: string preAllocatedHandle?: string tabId?: string leafId?: string sessionId?: string isNewSession?: boolean persistHostSessionBinding?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } agentSessionCreateOperationId?: string signal?: AbortSignal onPtySpawnCommitted?: () => void adoptedStablePane?: { result: PtySpawnResult owner: { handle?: string tabId: string leafId: string ptyId: string incarnationId?: string } materialized?: true } }): Promise<{ id: string incarnationId?: PtyIncarnationId wslDistro?: string stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ attach?(ptyId: string): Promise kill(ptyId: string): boolean stopAndWait?( ptyId: string, opts?: { keepHistory?: boolean; deadlineMs?: number } ): Promise markReversibleStops?(ptyIds: readonly string[]): () => void getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise inspectProcess?( ptyId: string ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean // Why: exact-id mobile polls should not enumerate every local and SSH PTY. hasPty?(ptyId: string): boolean | null listProcesses?(connectionId?: string | null): Promise serializeBuffer?( ptyId: string, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } ): Promise<{ data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null> /** Authoritative provider-owned snapshot for restored PTYs with no mounted renderer. */ serializeProviderBuffer?( ptyId: string, opts?: { scrollbackRows?: number } ): Promise // Why: synchronous probe used by maybeHydrateHeadlessFromRenderer to skip // hydration when no renderer is authoritative for this PTY. See // docs/mobile-prefer-renderer-scrollback.md. hasRendererSerializer?(ptyId: string): boolean getRendererSerializerGeneration?(ptyId: string): number waitForRendererSerializer?( ptyId: string, afterGeneration: number, timeoutMs?: number, signal?: AbortSignal ): Promise getSize?(ptyId: string): { cols: number; rows: number } | null /** False only when the owning provider proved the PTY absent; null = unknown (never a denial). */ probePtyLiveness?(ptyId: string): Promise } type PtyControllerTerminalIdentity = Readonly<{ handle: string incarnationId: string wslDistro?: string | null }> type PtyControllerInventory = Readonly<{ livePtyIds: ReadonlySet // Why: livePtyIds is worktree-scoped when a target is given; absence proofs // must consult the unscoped inventory or a misattributed live PTY reads as dead. allLivePtyIds: ReadonlySet terminalIdentityByPtyId: ReadonlyMap }> type WorktreeStartupDraftPaste = { agent: TuiAgent content: string } type WorktreeStartupFollowup = { expectedProcess: string prompt: string } function getAgentLaunchPlatformForRepo( repo: Pick, projectRuntime?: ProjectExecutionRuntimeResolution ): NodeJS.Platform { if (!repo.connectionId) { if (projectRuntime?.status === 'repair-required') { return projectRuntime.repair.preferredRuntime.kind === 'wsl' ? 'linux' : process.platform } if (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') { return 'linux' } return process.platform } return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' } // Why: long enough for a phone to reconnect and retry a create whose response // was lost, short enough that an intentional later re-resume forks fresh. const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 // Why: same idempotency window for worktree.create — a phone whose create was // interrupted by a connection migration retries with the same clientMutationId // and reuses the just-created worktree instead of spawning a duplicate. const WORKTREE_CREATE_RESULT_TTL_MS = 60_000 const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' const BRACKETED_PASTE_END = '\x1b[201~' const BRACKETED_PASTE_QUIET_MS = 1500 const DRAFT_PASTE_READY_TIMEOUT_MS = 8000 const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000 const MOBILE_TERMINAL_READY_FALLBACK_MS = 1000 const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 const SSH_PANE_RECOVERY_GRACE_MS = 30_000 // Why: long enough that a keystroke burst to a proven-dead leaf probes once, // short enough that a recreated session id regains writability quickly even if // its runtime record (which also invalidates the verdict) is late. const PROVEN_ABSENT_LEAF_PTY_TTL_MS = 15_000 function isClientDisconnectedError(error: unknown): boolean { return error instanceof Error && error.message === 'client_disconnected' } function createTerminalRevealWarning(handle: string, error?: unknown): string { const reason = error instanceof Error && error.message.trim().length > 0 ? ` Reason: ${error.message.trim()}.` : '' return [ `Terminal ${handle} is running, but Orca could not make it discoverable.${reason}`, `Run \`orca terminal focus --terminal ${handle}\` to reveal and focus it.` ].join(' ') } // Why: an absent `surfaceOwner` means "default", so surfacing callers must omit // the key rather than send `true`. function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } { return shouldSurface ? {} : { surfaceOwner: false } } function resolveTerminalPresentation(opts: { presentation?: RuntimeTerminalPresentation focus?: boolean activate?: boolean }): RuntimeTerminalPresentation | undefined { if (opts.presentation) { return opts.presentation } if (opts.focus === true || opts.activate === true) { return 'focused' } return undefined } type RuntimeNotifier = { worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void reposChanged(): void activateWorktree( repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void createTerminal( worktreeId: string, opts: { command?: string cwd?: string env?: Record title?: string presentation?: RuntimeTerminalPresentation } ): void revealTerminalSession?( worktreeId: string, opts: { ptyId: string title?: string | null cwd?: string launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation surfaceOwner?: false tabId?: string leafId?: string splitFromLeafId?: string splitDirection?: 'horizontal' | 'vertical' splitTelemetrySource?: TerminalPaneSplitSource focus?: boolean expectedProcessIdentity?: { terminalHandle: string incarnationId: string } } ): | Promise<{ tabId: string; title?: string | null; identity?: TerminalRevealIdentity }> | { tabId: string; title?: string | null; identity?: TerminalRevealIdentity } | void resolveLegacyWorkerTerminalRecovery?( paneKey: string, resolution: 'adopted' | 'exited' | 'rolled_back', ptyId?: string ): void splitTerminal( tabId: string, paneRuntimeId: number, opts: { direction: 'horizontal' | 'vertical' command?: string telemetrySource?: TerminalPaneSplitSource } ): void renameTerminal(tabId: string, title: string | null): void focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void focusEditorTab?(tabId: string, worktreeId: string): void closeSessionTab?(tabId: string, worktreeId: string): void moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void openFile?( worktreeId: string, filePath: string, relativePath: string, runtimeEnvironmentId?: string | null ): void openDiff?( worktreeId: string, filePath: string, relativePath: string, staged: boolean, runtimeEnvironmentId?: string | null ): void readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise saveMobileMarkdownTab?( worktreeId: string, tabId: string, baseVersion: string, content: string ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void closeTerminalTab?(tabId: string): Promise sleepWorktree(worktreeId: string): void // Why: a phone opening a worktree wakes its slept agents by asking the host // renderer to run its own navigation-free wake (experimental agent sleep); // the runtime has no in-memory sleeping records or wake authority. Optional to // match the many renderer-backed notifier methods only the real bridge wires. resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', cols: number, rows: number ): void // Why: presence-based lock signal — desktop renderer mounts the lock // banner when `driver.kind === 'mobile'` and unmounts otherwise. The // structured payload (vs a `locked: boolean`) carries the active mobile // actor's clientId so the renderer can disambiguate multi-phone scenarios // and so a future write coordinator can use the same signal as scheduling // input. See docs/mobile-presence-lock.md. terminalDriverChanged(ptyId: string, driver: DriverState): void nativeChatLaunchDraftResolved?( tabId: string, resolution: { text: string; createdAt: number } ): void browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void } type TerminalHandleRecord = { handle: string runtimeId: string rendererGraphEpoch: number worktreeId: string tabId: string leafId: string ptyId: string | null ptyGeneration: number } export type OrchestrationCompatibilityTerminalAuthority = { runtimeId: string terminalHandle: string ptyId: string worktreeId: string processIncarnation: string | null paneKey: string | null launchTokenHash: string | null hostScope: | { kind: 'local'; hostId: 'local' } | { kind: 'wsl'; hostId: 'local'; distro: string } | { kind: 'ssh'; targetId: string } } export type LegacyWorkerTerminalRecoveryResult = { blockedPaneCount: number adoptedDispatchIds: string[] exitedDispatchIds: string[] deferredDispatchIds: string[] } export type OrchestrationCompatibilityCallerAuthority = Readonly<{ hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] paneKey: string terminalHandle: string processIncarnation: string launchTokenHash: string }> type RestoredOrchestrationAuthorityReceipt = Readonly<{ ptyId: string worktreeId: string terminalHandle: string paneKey: string processIncarnation: string hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] }> type OrchestrationCompatibilitySshAttachmentAuthority = Extract< OrchestrationCompatibilityHostStamp, { kind: 'ssh' } > type TerminalWaiter = { handle: string condition: RuntimeTerminalWaitCondition resolve: (result: RuntimeTerminalWait) => void reject: (error: Error) => void timeout: NodeJS.Timeout | null pollInterval: NodeJS.Timeout | null abortCleanup: (() => void) | null } type MessageWaiter = { handle: string typeFilter: string[] | undefined resolve: (result: MessageWaitResult) => void timeout: NodeJS.Timeout | null abortCleanup: (() => void) | null } export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists' function omitUndefinedProperties>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) ) as Partial } async function isRuntimeWorktreePathMissing( repo: Repo, worktreePath: string, localWorktreeGitOptions: { wslDistro?: string } = {} ): Promise { if (!repo.connectionId) { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) return isWorktreePathMissing( toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), access.statPath ) } const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return false } return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) } async function isLocalRuntimeGitRepository( runtimeWorktreePath: string, localWorktreeGitOptions: { wslDistro?: string } = {} ): Promise { try { await gitExecFileAsync(['status', '--short'], { cwd: runtimeWorktreePath, ...localWorktreeGitOptions }) return true } catch (error) { return !gitStatusErrorMeansNotRepository(error) } } function gitStatusErrorMeansNotRepository(error: unknown): boolean { const message = error instanceof Error ? error.message : error && typeof error === 'object' && 'message' in error ? String((error as { message: unknown }).message) : typeof error === 'string' ? error : '' const stderr = error && typeof error === 'object' && 'stderr' in error ? String((error as { stderr: unknown }).stderr) : '' return /not a git repository/i.test(`${message}\n${stderr}`) } type RuntimeWorktreeRemovalTarget = { id: string repoId: string path: string pushTarget?: GitPushTarget } type RuntimeWorktreeRemovalInFlight = { optionsKey: string promise: Promise } type PreservedBranchCleanupTarget = { branchName: string head: string pushTarget?: GitPushTarget } function getRuntimeWorktreeRemovalOptionsKey( force: boolean, runHooks: boolean, allowUnverifiedPtyStop: boolean ): string { // Why: a forced retry must not coalesce onto the in-flight attempt that just // failed the PTY gate — it would inherit that failure instead of retrying. const ptyKey = allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop' return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}` } function getRuntimeFolderWorkspaceRootId(repo: Repo): string { return `${repo.id}::${repo.path}` } // Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime // host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo // (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. function runtimeRepoMatchesExecutionHost( repo: Pick, executionHostId?: ExecutionHostId | null ): boolean { if (executionHostId == null) { return true } if (repo.executionHostId != null) { return repo.executionHostId === executionHostId } return repo.connectionId == null } // Why: this runtime only has local git and local fs, so an ssh: host here would clone and // probe the wrong machine and then register the result as remote. SSH setup is owned by the // desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to; // only `local` and `runtime:` legitimately reach these RPCs. function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void { if (parseExecutionHostId(hostId)?.kind !== 'ssh') { return } throw new Error( 'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.' ) } function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } function getRuntimeFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { const prefix = `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() } function isRuntimeFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { const rootId = getRuntimeFolderWorkspaceRootId(repo) return ( worktreeId === rootId || worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) ) } function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { return { id: worktreeId, ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), repoId: repo.id, ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), ...(meta.projectHostSetupId !== undefined ? { projectHostSetupId: meta.projectHostSetupId } : {}), path: repo.path, head: '', branch: '', isBare: false, isMainWorktree: worktreeId === getRuntimeFolderWorkspaceRootId(repo), displayName: meta.displayName || repo.displayName, comment: meta.comment || '', linkedIssue: meta.linkedIssue ?? null, linkedPR: meta.linkedPR ?? null, linkedLinearIssue: meta.linkedLinearIssue ?? null, linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, linkedGitLabMR: meta.linkedGitLabMR ?? null, linkedGitLabIssue: meta.linkedGitLabIssue ?? null, linkedBitbucketPR: meta.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, linkedGiteaPR: meta.linkedGiteaPR ?? null, linkedWorkItem: meta.linkedWorkItem ?? null, linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, isArchived: meta.isArchived ?? false, isUnread: meta.isUnread ?? false, isPinned: meta.isPinned ?? false, sortOrder: meta.sortOrder ?? 0, ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), lastActivityAt: meta.lastActivityAt ?? 0, ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), ...(meta.automationProvenance !== undefined ? { automationProvenance: meta.automationProvenance } : {}), ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, mobileDiffReview: meta.mobileDiffReview } } function listRuntimeFolderWorkspaces( store: Pick, repo: Repo ): Worktree[] { const rootId = getRuntimeFolderWorkspaceRootId(repo) const allMeta = store.getAllWorktreeMeta() const ids = Object.keys(allMeta).filter((worktreeId) => isRuntimeFolderWorkspaceIdForRepo(repo, worktreeId) ) if (!ids.includes(rootId)) { ids.unshift(rootId) } else { ids.sort((left, right) => { if (left === rootId) { return -1 } if (right === rootId) { return 1 } return 0 }) } return ids.map((worktreeId) => { const existing = allMeta[worktreeId] const meta = existing?.instanceId ? existing : store.setWorktreeMeta(worktreeId, { instanceId: getRuntimeFolderWorkspaceInstanceIdentity(repo, worktreeId), ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) }) return mergeRuntimeFolderWorkspace(repo, worktreeId, meta) }) } function parseExactWorktreeIdSelector(selector: string): RuntimeWorktreeRemovalTarget | null { const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = splitWorktreeId(worktreeId) if (!parsed || !parsed.repoId || !parsed.worktreePath) { return null } return { id: worktreeId, repoId: parsed.repoId, path: parsed.worktreePath } } async function resolveCreateBranchName( repoPath: string, branchNameOverride: string | undefined, sanitizedName: string, settings: { branchPrefix: string; branchPrefixCustom?: string }, username: string | null, gitOptions: { wslDistro?: string } = {} ): Promise { if (!branchNameOverride) { // The runtime store's getSettings() types branchPrefix loosely as string; // it is always one of the BranchPrefixStrategy literals at runtime. return computeValidatedBranchName( sanitizedName, { ...settings, branchPrefix: settings.branchPrefix as BranchPrefixStrategy }, username ) } if (branchNameOverride.startsWith('-')) { throw new Error('Branch name must not start with "-"') } await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath, ...gitOptions }) return branchNameOverride } function normalizeLocalBranchName(branchName: string | undefined): string { return branchName?.replace(/^refs\/heads\//, '') ?? '' } // Clamp terminal dimensions to the PTY's supported range (cols 20–240, rows 8–120). function clampTerminalViewport(cols: number, rows: number): { cols: number; rows: number } { return { cols: Math.max(20, Math.min(240, Math.round(cols))), rows: Math.max(8, Math.min(120, Math.round(rows))) } } // Subscribe a listener to a per-key Set, pruning the key's entry once its last // listener unsubscribes. Returns the unsubscribe callback. function addListenerToMap(map: Map>, key: string, listener: T): () => void { let listeners = map.get(key) if (!listeners) { listeners = new Set() map.set(key, listeners) } const set = listeners set.add(listener) return () => { set.delete(listener) if (set.size === 0) { map.delete(key) } } } async function canCheckoutExistingLocalBranch( repoPath: string, branchName: string, baseBranch: string, gitOptions: { wslDistro?: string } = {} ): Promise { let localHead = '' try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`], { cwd: repoPath, ...gitOptions } ) localHead = stdout.trim() } catch { return false } if (normalizeLocalBranchName(baseBranch) !== branchName) { if (!localHead) { return false } try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], { cwd: repoPath, ...gitOptions } ) if (stdout.trim() !== localHead) { return false } } catch { return false } } const worktrees = await listWorktrees(repoPath, gitOptions) return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName) } function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { return Object.keys(gitOptions).length > 0 } function getLocalGitHubPrForBranch( repoPath: string, branchName: string, gitOptions: { wslDistro?: string } ): ReturnType { return hasLocalGitOptions(gitOptions) ? getPRForBranch(repoPath, branchName, null, null, null, { localGitExecOptions: gitOptions }) : getPRForBranch(repoPath, branchName) } type SelectedReviewBranchInput = { branchNameOverride?: string linkedPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null pushTarget?: GitPushTarget } type SelectedReviewBranch = { provider: ForgeProviderId number: number } function getSelectedReviewBranch(args: SelectedReviewBranchInput): SelectedReviewBranch | null { if (typeof args.linkedPR === 'number') { return { provider: 'github', number: args.linkedPR } } if (typeof args.linkedGitLabMR === 'number') { return { provider: 'gitlab', number: args.linkedGitLabMR } } if (typeof args.linkedBitbucketPR === 'number') { return { provider: 'bitbucket', number: args.linkedBitbucketPR } } if (typeof args.linkedAzureDevOpsPR === 'number') { return { provider: 'azure-devops', number: args.linkedAzureDevOpsPR } } if (typeof args.linkedGiteaPR === 'number') { return { provider: 'gitea', number: args.linkedGiteaPR } } return null } function isSelectedGitHubPrBranchOverride( args: SelectedReviewBranchInput, branchName: string ): boolean { return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName } function isSelectedReviewBranchOverride( args: SelectedReviewBranchInput, branchName: string ): boolean { return getSelectedReviewBranch(args) !== null && args.branchNameOverride === branchName } function isMatchingSelectedGitHubPr( existingPR: Awaited>, args: SelectedReviewBranchInput, branchName: string ): boolean { return Boolean( existingPR && isSelectedGitHubPrBranchOverride(args, branchName) && existingPR.number === args.linkedPR ) } function isAllowedPushTargetRemoteConflict( conflictKind: 'local' | 'remote' | null, branchName: string, args: SelectedReviewBranchInput ): boolean { return ( conflictKind === 'remote' && isSelectedReviewBranchOverride(args, branchName) && args.pushTarget?.branchName === branchName ) } function getSelectedReviewLookupHints(args: SelectedReviewBranchInput): { linkedGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null } { return { linkedGitHubPR: args.linkedPR ?? null, linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null } } async function getSelectedHostedReviewForBranch( repo: Pick, branchName: string, args: SelectedReviewBranchInput, executionOptions: { localGitExecOptions?: { wslDistro?: string } } = {} ): Promise<{ matchesSelected: boolean; number: number } | null> { const selectedReview = getSelectedReviewBranch(args) if (!selectedReview) { return null } const review = await getHostedReviewForBranchFromRepo({ repoPath: repo.path, connectionId: repo.connectionId ?? null, branch: branchName, ...executionOptions, ...getSelectedReviewLookupHints(args) }) if (!review) { return null } return { matchesSelected: review.provider === selectedReview.provider && review.number === selectedReview.number, number: review.number } } async function pathExists(pathValue: string): Promise { try { await stat(pathValue) return true } catch (error) { if (isENOENT(error)) { return false } throw error } } function resolveServerBrowsePath(pathValue: string): string { const trimmed = pathValue.trim() || '~' if (trimmed.includes('\0')) { throw new Error('Path cannot contain null bytes') } if (trimmed === '~') { return homedir() } if (/^~[\\/]/.test(trimmed)) { return resolve(homedir(), trimmed.slice(2)) } if (isAbsolute(trimmed)) { return resolve(trimmed) } // Why: remote clients do not share the server process cwd; relative browse // inputs are anchored to the server user's home to match the `~` picker root. return resolve(homedir(), trimmed) } type ResolvedWorktree = Worktree & { parentWorktreeId: string | null childWorktreeIds: string[] lineage: WorktreeLineage | null git: GitWorktreeInfo } type LinearAgentWriteTarget = { issue: LinearIssueSummary workspaceId: string } type LinearCreateFieldIntent = { stateId?: string assigneeId?: string | null priority?: number estimate?: number | null dueDate?: string | null labelIds?: string[] projectId?: string } const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', 'ORCA_AGENT_HOOK_ENDPOINT' ] as const function sameStringSet(left: string[], right: string[]): boolean { if (left.length !== right.length) { return false } const rightSet = new Set(right) return left.every((value) => rightSet.has(value)) } function labelsForIds( ids: string[], labels: { id?: string | null; name?: string | null; color?: string | null }[] ): { id: string; name: string; color?: string | null }[] { return ids.map((id) => { const label = labels.find((candidate) => candidate.id === id) return { id, name: label?.name ?? id, ...(label?.color ? { color: label.color } : {}) } }) } type TerminalWorkspaceLaunchScope = { id: string path: string connectionId: string | null repo: Repo | null folderWorkspace: FolderWorkspace | null } type WorktreeLineageInput = { parentWorkspace?: string envParentWorkspace?: string parentWorktree?: string cwdParentWorktree?: string noParent?: boolean callerTerminalHandle?: string comment?: string orchestrationContext?: { parentWorktreeId?: string orchestrationRunId?: string taskId?: string coordinatorHandle?: string } } type ResolvedWorkspaceParent = | { type: 'worktree' workspaceKey: WorkspaceKey worktree: ResolvedWorktree instanceId: string | null } | { type: 'folder' workspaceKey: WorkspaceKey folderWorkspace: FolderWorkspace instanceId: string | null } type WorktreeLineageResolution = | { kind: 'lineage' parent: ResolvedWorkspaceParent origin: WorktreeLineage['origin'] capture: WorktreeLineage['capture'] orchestrationRunId?: string taskId?: string coordinatorHandle?: string createdByTerminalHandle?: string } | { kind: 'none' warnings: WorktreeLineageWarning[] } type RuntimeWorktreeScanResult = | { ok: true; worktrees: GitWorktreeInfo[] } | { ok: false; worktrees: GitWorktreeInfo[] } type RuntimeWorktreeScanCache = { generation: number runtimeKey: string result: Extract expiresAt: number } type RuntimeWorktreeScanInFlight = { generation: number runtimeKey: string promise: Promise } type WorktreeLineageCandidate = { source: 'env-workspace' | 'cwd-context' | 'terminal-context' | 'orchestration-context' parent: ResolvedWorkspaceParent orchestrationRunId?: string taskId?: string coordinatorHandle?: string } function extractOrchestrationTaskId(text?: string): string | undefined { return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0] } class RuntimeLineageError extends Error { code: string data?: unknown constructor(code: string, message: string, data?: unknown) { super(message) this.code = code this.data = data } } class WorktreeIdRequiresFullPathError extends Error { readonly code = 'worktree_id_requires_full_path' constructor() { super( 'Worktree id selectors must use the full :: value. Use the id from `orca worktree list --json`, or target by path:, branch:, or issue:.' ) } } type ResolvedWorktreeSnapshot = { worktrees: ResolvedWorktree[] platformByRepoId: ReadonlyMap } type ResolvedWorktreeCache = ResolvedWorktreeSnapshot & { expiresAt: number } type ResolvedWorktreeInFlight = { generation: number promise: Promise } // Why: notificationSeq is the desktop-assigned monotonic sequence used for // mobile reconnect catch-up (#8129). It is added on dispatch (and replay) so a // client can watermark the last event it delivered and request exactly the // events after it — idempotent, no duplicate local pushes. export type MobileNotificationDispatchEvent = { type: 'notification' source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin' title: string body: string worktreeId?: string notificationId?: string notificationSeq?: number notificationEpoch?: string } export type RuntimeWorktreeLifecycleEvent = | { kind: 'created'; worktreeId: string; path: string; branch: string } | { kind: 'removed'; worktreeId: string; path: string } export type MobileNotificationDismissEvent = { type: 'dismiss' notificationId: string notificationSeq?: number notificationEpoch?: string } export type MobileNotificationEvent = | MobileNotificationDispatchEvent | MobileNotificationDismissEvent // Why: presence-based driver state for the mobile-presence lock. Exactly one // driver per PTY at any moment. See docs/mobile-presence-lock.md. // - `idle`: no mobile subscribers; desktop input flows freely // - `desktop`: at least one mobile client subscribed but desktop reclaimed // (or all mobile clients are passive `desktop`-mode watchers); desktop // input flows freely // - `mobile{clientId}`: a mobile client is the active driver; desktop // input/resize are dropped server-side and the lock banner is mounted. // `clientId` is the most recent mobile actor for this PTY. export type DriverState = RuntimeTerminalDriverState // Why: per-PTY layout target — what the PTY *should* be at right now. // `desktop` ⇒ runs at the desktop renderer's pane geometry; mobile passive // watchers (mode='desktop') still receive scrollback. `phone` ⇒ runs at // `ownerClientId`'s viewport; the desktop renderer's auto-fit is suppressed. // See docs/mobile-terminal-layout-state-machine.md. export type PtyLayoutTarget = | { kind: 'desktop'; cols: number; rows: number } | { kind: 'phone'; cols: number; rows: number; ownerClientId: string } | { kind: 'remote-desktop'; cols: number; rows: number; ownerSubscriptionKey: string } // Why: authoritative layout state with monotonic seq. Bumped on every // applyLayout success; emitted on mobile subscribe-stream events so clients // drop stale events that arrive after a newer transition. export type PtyLayoutState = PtyLayoutTarget & { seq: number appliedAt: number } // Why: applyLayout result discriminator. Callers (especially RPC handlers) // need to distinguish "shipped a new state at seq N" from "no-op — caller // should not claim a seq it didn't produce." `pty-exited` is terminal; // `resize-failed` is transient and the caller may retry. export type ApplyLayoutResult = | { ok: true; state: PtyLayoutState } | { ok: false; reason: 'pty-exited' | 'resize-failed' } type LayoutQueueEntry = { running: Promise | null pending: { target: PtyLayoutTarget waiters: ((r: ApplyLayoutResult) => void)[] }[] } type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & { worktreeId: string } const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200 async function hasLocalWorktreeBaseRef( repoPath: string, baseRef: string, options: { wslDistro?: string } = {} ): Promise { const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists) if (resolvedBaseRef !== baseRef) { return true } if (baseRef.startsWith('refs/')) { return refExists(baseRef) } return hasCommitObjectViaGitExec( (gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }), baseRef ) } function getSetupRunnerCommandPlatformForLaunch( setup: CreateWorktreeResult['setup'], fallbackPlatform: 'windows' | 'posix' ): 'windows' | 'posix' { return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform) } export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() private readonly store: RuntimeStore | null private managedHookReconciliationGeneration = 0 private managedHookReconciliationTail: Promise = Promise.resolve() private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null private readonly orchestrationFederationTimers = new Map>() private readonly orchestrationFederationSyncs = new Map>() private readonly orchestrationFederationWarnings = new Set() private rendererGraphEpoch = 0 private graphStatus: RuntimeGraphStatus = 'unavailable' private authoritativeWindowId: number | null = null // Why: paired graph transactions need foreground timer cadence only until their publication settles. private readonly rendererPublicationThrottle = new RendererPublicationThrottle() private tabs = new Map() private mobileSessionTabsByWorktree = new Map() // Why: renderer publication ordering must be judged against the renderer's // own last-accepted (epoch, version) — never against the stored snapshot's // version, which main-local touches bump independently and can push // permanently ahead of the renderer's counter. The renderer reuses one pair // for byte-identical content, so a same-epoch version <= this one is a no-op // resend (or stale) and is skipped without touching the stored entry. private acceptedRendererMobileSnapshotByWorktree = new Map< string, { publicationEpoch: string rendererVersion: number rendererTabCount: number rendererTabIdentityKeys: ReadonlySet } >() private clientSessionTabSelections = new ClientSessionTabSelectionStore() // Why: idempotency map for mobile terminal creation — a retried create with the // same clientMutationId returns the in-flight operation instead of duplicating. private mobileTerminalCreateByMutationId = new Map< string, Promise >() private readonly terminalCreateIdempotency = new RemoteRuntimeTerminalCreateIdempotency() // Why: concurrent clients sleeping one host workspace must share one physical teardown. private terminalSleepByWorktreeId = new Map>() private terminalMutationTailByWorktreeId = new Map>() private terminalSleepStateByWorktreeId = new Map< string, { worktreeId: string generation: number phase: 'stopping' | 'partial' | 'sleeping' ptyIds: string[] terminalHandles: string[] terminalHandlesByPtyId: Record } >() private terminalSleepGeneration = 0 private terminalPaneRecoveryByIdentity = new Map>() // Why: idempotency map for worktree.create — a create interrupted by a mobile // connection migration is retried with the same clientMutationId and returns // the in-flight (or just-finished) operation instead of a duplicate worktree. private worktreeCreateByMutationId = new Map>() // Why: a mobile create waits for the renderer to publish the new tab's surface // via graph-sync, but a throttled/hidden renderer can park that past the surface // timeout and the create would then destroy the live PTY (#7587). This lets the // renderer's own PTY spawn publish the surface main-side, scoped to in-flight // creates so ordinary renderer spawns never publish here. private pendingMobileTerminalCreatesByKey = new Map< string, { activate: boolean paired: boolean selectIfNoActiveTab: boolean viewMode?: 'terminal' | 'chat' /** Resolved agent launch command, kept so a settle over a bare renderer * PTY can still deliver the launch instead of succeeding silently (STA-3214). */ startupCommand?: string } >() private mobileSessionTabListeners = new Set<{ listener: (snapshot: RuntimeMobileSessionTabsResult) => void clientNavigationId?: string }>() // Why: one watermark per repo replaces per-closed-pane fences while preserving stale-write safety. private terminalTopologyRevisionByRepoId = new Map() // Why: provider exit can beat surface registration; that exact dead incarnation must never publish. private earlyExitedPtyIncarnations = new Map() private pendingPtyRegistrationIncarnations = new Map() // Why: exact-stop is the current sleep transaction boundary; its exit must // leave the renderer's intentional sleeping surface available for wake. private intentionalHandlelessPtyStops = new Map() // Why: coalesces title/status-driven session.tabs emits so spinner churn // doesn't fan out (and per-client JSON.stringify) a snapshot several times a // second. Emit reads the latest snapshot, so only the freshest version ships. private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = createMobileSessionTabsNotifyCoalescer((worktreeId) => this.notifyMobileSessionTabsChangedNow(worktreeId) ) // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) // each await a full host reveal; only one terminal can be focused, so latest-wins // single-flight bounds host work. Does not replace cheaper activation or // reconnect-scan bounding for sequential soft freezes. private readonly terminalFocusNavigationCoalescer = new TerminalFocusNavigationCoalescer() private pendingMobileSessionPtyInventoryRefresh: Promise | null> | null = null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. private leavesByPtyId = new Map() private handles = new Map() private handleByLeafKey = new Map() private handleByPtyId = new Map() private syntheticTerminalHandles = new Set() private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() // Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production. private terminalSideEffectExcludedClientEventListeners = new Set< (event: RuntimeClientEvent) => void >() private terminalSideEffectTitleGateKeysByClientEventListener = new Map< (event: RuntimeClientEvent) => void, Map >() private nativeChatLaunchDraftResolutionByTabId = new Map< string, NativeChatLaunchDraftResolutionTombstone >() private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null private offscreenBrowserBackend: BrowserBackend | null = null private emulatorBridge: EmulatorBridge | null = null private resolvedWorktreeCache: ResolvedWorktreeCache | null = null private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null private resolvedWorktreeGeneration = 0 private worktreeScanGenerations = new Map() private worktreeScanCache = new Map() private worktreeScanInFlight = new Map() private cloneInFlightByPath = new Map>() private agentDetector: AgentDetector | null = null private ptyForegroundAgentRefreshes = new Map() private ptyDelayedForegroundSnapshotTitleObservations = new Map() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // Why: mobile clients subscribe to terminal output via terminal.subscribe. // These listeners fire on every onPtyData call, enabling real-time streaming // without polling. Keyed by ptyId for O(1) lookup per data event. private dataListeners = new Map< string, Set<(data: string, meta?: RuntimeTerminalDataMeta) => void> >() private remoteTerminalSourceRangeConsumerHooks: RemoteTerminalSourceRangeConsumerHooks | null = null // Why: startup draft paste can subscribe after the agent already emitted its // ready marker. Keep a bounded raw buffer so fast startup output is replayed. private recentPtyOutputById = new Map() private setupCompletionTokenByPtyId = new Map() // Why: mobile clients need to know when the desktop restores a terminal // from mobile-fit so they can update their UI. These listeners are // invoked from resizeForClient and onClientDisconnected/onPtyExit. private fitOverrideListeners = new Map< string, Set< (event: { mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void > >() private driverListeners = new Map void>>() private subscriptionCleanups = new Map void | Promise>() private subscriptionCleanupPromises = new Map< string, { cleanup: () => void | Promise; promise: Promise } >() // Why: index of subscriptionIds by per-WebSocket connectionId so the // server can sweep all subscriptions for a closing socket without // touching subscriptions on other live sockets that share the same // deviceToken (multi-screen mobile). private subscriptionsByConnection = new Map>() private subscriptionConnectionByEntry = new Map() private activeBrowserScreencastsByConnection = new Map< string, { cancel: (emitEnd?: boolean) => void; done: Promise; connectionKey: string } >() private activeBrowserScreencastsByPage = new Map< string, { cancel: (emitEnd?: boolean) => void; done: Promise; connectionKey: string } >() // Why: mobile clients subscribe to desktop notifications via // notifications.subscribe. This set enables fan-out — each connected // mobile client gets its own listener, and dispatchMobileNotification // iterates them all. Listeners are cleaned up via subscriptionCleanups. private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() private readonly pairedRendererSessionOwnedPtyIds = new Set() private wslDistroByPtyId = new Map() private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() private providerSequenceInitializedPtys = new Set() private providerSequenceOffsetByPtyId = new Map() private providerSnapshotPreferredPtys = new Set() private providerModeTrackersByPtyId = new Map() private providerModeSnapshotScansByPtyId = new Map< string, Set >() private providerBufferAcquisitionsByPtyId = new Map() private providerVisibleStateByPtyId = new Map() private providerVisibleRetryAtByPtyId = new Map() private providerSnapshotsWithLiveModeTransition = new WeakSet() private ptyLifecycleGenerationById = new Map() private nextPtyLifecycleGeneration = 1 private recentPtyPathCandidatesById = new Map() // Why: candidates only feed mobile file-tap provenance; desktop-only // sessions skip the 3-regex extraction on every PTY chunk until a // mobile/remote client authenticates (sticky, backfilled on activation). private recentPtyPathCandidateTrackingActive = false // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. // Why a throttle: the blocked-reason check builds and scans two full wait // texts (<=256KB each, lowercased) — measured at ~85% of onPtyData's cost // under a TUI flood (findings log 2026-07-03). PTY chunk boundaries are // arbitrary, so running the identical computation over coalesced chunks at // a bounded cadence (plus a trailing-edge timer so burst-final state is // always evaluated) preserves semantics while removing it from the hot path. private waitBlockedCheckStateByPtyId = new Map< string, { lastAt: number lastWaitState: TerminalTailWaitState | null appended: string keywordCarry: string timer: ReturnType | null } >() private agentStatusOscProcessorsByPtyId = new Map< string, ReturnType >() // Why: per-PTY shared title trackers (all-titles ordering + stale-working // timer) replace last-title-per-chunk scanning so main observes the same // intra-chunk working→idle transitions the renderer does (issue #1083). // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. private ptyTitleTrackersByPtyId = new Map() // Why: the Command Code output detector arms early from the launch command // when known (banner detection covers user-typed launches), mirroring the // renderer detector's startupCommand seed. private terminalSpawnCommandsByPtyId = new Map() // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over // SSH/relay buffering. Keep a small raw scan tail and feed reconstructed // chunks into the title tracker instead of falling back to last-title scans. private oscTitleScanTailByPtyId = new Map() // Why: mobile file taps resolve relative paths on the host. OSC 7 is the // terminal-owned cwd signal, and it can arrive in live output between snapshots. private osc7ScanTailByPtyId = new Map() private terminalCwdByPtyId = new Map() private terminalFileUriHostnameByPtyId = new Map() // Why: latest agent-status payload per pane, retained so worktree.ps can serve // mobile the same inline agent rows the desktop sidebar renders. Cleared on pty // teardown so dead agents don't linger. See RuntimeAgentRowSnapshot. private latestAgentStatusByPaneKey = new Map() // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again // Absent → hydration has not been considered yet for this PTY. // See docs/mobile-prefer-renderer-scrollback.md. private headlessHydrationState = new Map() // Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because // handles can be reissued while the PTY identity is stable. In-memory only — // a stale phone override should not survive an app restart. private terminalFitOverrides = new Map< string, { mode: 'mobile-fit' cols: number rows: number previousCols: number | null previousRows: number | null updatedAt: number clientId: string } >() // Why: server-authoritative display mode per terminal. 'auto' (default) // means phone-fit when mobile subscribes, desktop otherwise. 'desktop' // locks to no-resize regardless of subscriber state. The third historical // value ('phone' = sticky phone-fit after unsubscribe) was removed since // the toggle UI never produced it and nothing in product depended on it. // In-memory only — modes reset on restart. private mobileDisplayModes = new Map() // Why: tracks active mobile subscribers per PTY so the runtime can restore // desktop dimensions on unsubscribe and prevent orphaned overrides during // rapid tab switches. Keyed by ptyId → inner map of clientId → subscriber. // The two-level map preserves multi-mobile soundness: phone B subscribing // does not silently overwrite phone A's record. See // docs/mobile-presence-lock.md "Multi-mobile subscriber model". // subscribedAt drives "earliest-by-subscribe-time" restore-target selection // (only among subscribers with non-null previousCols/Rows; desktop-mode // joins carry null and are skipped). lastActedAt drives "most-recent // actor's viewport wins" for active phone-fit dims. private mobileSubscribers = new Map< string, Map< string, { clientId: string viewport: { cols: number; rows: number } | null wasResizedToPhone: boolean previousCols: number | null previousRows: number | null subscribedAt: number lastActedAt: number } > >() // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe // stream feeds a remote xterm view (mobile/web/remote desktop) that answers // queries with view authority, so main must yield while one is attached // (terminal-query-authority.md). Ref-counted per PTY because multiple // streams can attach concurrently; mobileSubscribers is consulted too so // grace-window mobile records keep suppressing. private remoteTerminalViewSubscriberCounts = new Map() // Preview windows consume the raw stream but deliberately leave terminal // query replies to main's headless emulator. private rawTerminalViewSubscriberCounts = new Map() // Why a sticky promise per PTY: the daemon only emits data for sessions this // app has attached, so the first remote view subscriber of a never-attached // local daemon session triggers a main-side attach. The map dedupes // concurrent first-subscribes and keeps later subscribes no-ops; it is // cleared per lifecycle generation (exit/respawn) and on failed attempts. private subscriberDrivenProviderAttachesByPtyId = new Map>() // Why: a spawn through this app already attaches its provider stream, so // subscriber-driven attach and the never-attached read fallback must target // only inventory-discovered sessions no local spawn published this // generation (a replacement spawn under a reused id starts clean). private spawnPublishedPtys = new Set() // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops // xterm.onData/onResize and shows the lock banner; `terminal.send` / // `pty:write` and `pty:resize` IPC handlers also drop desktop-side calls // server-side as defense-in-depth. The `clientId` carried on the mobile // variant is the most recent mobile actor — used by // `applyMobileDisplayMode` to pick the active phone-fit viewport. See // docs/mobile-presence-lock.md. private currentDriver = new Map() private mobileInputFloorClaims = new Map< string, { base: DriverState generation: number committedGeneration: number pending: Map } >() private currentBrowserDriver = new Map() // Why: remote (relay/shared-control) desktop viewers of a PTY are keyed by // subscription, not client, because one client can open duplicate streams and // each stream must release only the width floor it registered. private remoteDesktopViewers = new Map< string, Map >() private remoteDesktopOwners = new Map() private remoteDesktopActivity = 0 private remoteDesktopHostReclaimTargets = new Map() // Why: a completed host reclaim must not consume the cache if a newer // viewer mutation landed while that serialized layout was in flight. private remoteDesktopViewerRevisions = new Map() // Why: resubscribe-grace window. When the last mobile subscriber for a // PTY unsubscribes, we hold the driver=mobile{clientId} state and the // inner-map record open for ~250ms. If the same (ptyId, clientId) // re-subscribes inside the window — typically because the mobile app // tore down the stream to reconfigure (rare with the new // updateMobileViewport path, but still possible on reconnects, network // hiccups, or older client builds) — we cancel the deferred idle and // restore-timer so the desktop banner doesn't flash and the new // subscriber doesn't capture an already-phone-fitted PTY size as its // restore baseline. Keyed by ptyId; carries the timer plus the snapshot // of the leaving subscriber so we can re-insert it on cancel. See // docs/mobile-presence-lock.md. private pendingSoftLeavers = new Map< string, { clientId: string timer: ReturnType record: { clientId: string viewport: { cols: number; rows: number } | null wasResizedToPhone: boolean previousCols: number | null previousRows: number | null subscribedAt: number lastActedAt: number } } >() // Why: tracks the last PTY size set by the desktop renderer (via pty:resize // IPC). Unlike ptySizes (which is overwritten by server-side phone-fit // resizes), this map preserves the actual pane geometry. Used as the // preferred source for previousCols so desktop restore uses the correct // split-pane width instead of a stale full-width value. private lastRendererSizes = new Map() // Why: when a desktop-fit override change fires, the desktop renderer's // re-render cascade (triggered by setOverrideTick) runs safeFit on ALL // panes — not just the affected one. Background tab panes get measured at // full-width (214) instead of their correct split width (105). The stale // pty:resize IPCs overwrite both the actual PTY size and lastRendererSizes. // This global window suppresses ALL pty:resize for 200ms after any // desktop-fit notification. The server has already set the correct PTY // size via ptyController.resize(), so desktop renderer resizes during // this window are redundant (for the restored pane) or wrong (collateral). private resizeSuppressedUntil = 0 // Why: delays PTY restore by 300ms after mobile unsubscribe so rapid tab // switches don't cause unnecessary resize thrashing. Keyed by clientId // Why: keyed by ptyId so each PTY gets its own independent restore timer. // The old clientId-keyed design lost timers when two PTYs were unsubscribed // back-to-back (only the last timer survived). private pendingRestoreTimers = new Map< string, { timer: ReturnType; clientId: string } >() // Why: inline resize events replace the unsubscribe→resubscribe pattern. // Listeners are notified when mode changes or desktop restores, allowing // the subscribe stream to emit a 'resized' event with fresh scrollback. // `seq` is the layout state-machine sequence number bumped on every // applyLayout success; mobile clients use it to drop stale events that // arrive after a newer transition. See docs/mobile-terminal-layout-state-machine.md. private resizeListeners = new Map< string, Set< (event: { cols: number rows: number displayMode: string reason: string seq?: number }) => void > >() // Why: per-PTY layout state machine. `applyLayout` is the sole writer of // `layouts`, `terminalFitOverrides`, and `ptyController.resize`; every // trigger method routes through `enqueueLayout`. The monotonic `seq` is // emitted on the mobile subscribe stream so clients can drop stale events. // See docs/mobile-terminal-layout-state-machine.md. private layouts = new Map() // Why: per-PTY async serialization queue for applyLayout. Without // serialization, two concurrent triggers can interleave around the // ptyController.resize await and bump seq in the wrong order, defeating // seq-as-truth. Coalesces same-kind same-owner viewport ticks so the // keyboard-show/hide animation doesn't queue 10+ resizes; mode flips, // take-floor, and different-owner targets always append (preserves // multi-mobile fairness). See docs/mobile-terminal-layout-state-machine.md // "enqueueLayout coalescing". private layoutQueues = new Map() // Why: gate so enqueueLayout's "no layouts entry" short-circuit doesn't // fire on the very first transition for a PTY (where the entry doesn't // exist yet *because* we're about to create it). `handleMobileSubscribe` // adds the ptyId before calling enqueueLayout and removes it after the // call resolves. private freshSubscribeGuard = new Set() private stats: StatsCollector | null = null // Why (§3.3 + §7.1): the renderer-create path and coordinator // `probeWorktreeDrift` share this cache so a create that already fetched // `origin` within the last 30s does not re-fetch during dispatch, and // vice-versa. Keyed by `::` so multi-remote repos (even // though v1 only uses `origin`) don't cross-contaminate. The in-flight Map // also provides serialization — two concurrent callers share a single // underlying `git fetch`. Full-remote fetch lifecycle rules: // - entry inserted BEFORE await, // - `.finally()` removes the entry on BOTH success and rejection, // - timestamp written ONLY on success (rejection must not make the // 30s freshness cache lie). // A literal "insert before await / read-back after await" without these // three rules wedges future fetches on the same repo after a single // DNS hiccup until process restart (see §3.3 Lifecycle). Exact base-ref // refreshes share the in-flight rule and maintain their own exact-base // freshness entries; a full-remote fetch may be narrowed by repo refspecs, // so it must not prove a specific branch for create. private fetchInflight = new Map>() // Why: `git fetch origin` and `git fetch origin ` contend for the // same repo remote/ref locks. This queue serializes all fetch shapes for one // canonical repo+remote while still letting same-shape callers share promises. private remoteFetchQueueTail = new Map>() private fetchLastCompletedAt = new Map() // Why: `getCanonicalFetchKey` is awaited from every freshness probe and // every getOrStartRemoteFetch call. Without memoization the warm-cache hot // path spawns a `git rev-parse --git-common-dir` subprocess per touch // (twice in createLocalWorktree). Cache by `::` so the // canonical key is resolved at most once per repo+remote in the process. private canonicalFetchKeyCache = new Map() private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() private preservedBranchCleanupByWorktreeId = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private terminalSideEffectLocalConsumerAvailable = false private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly getAgentProviderSessionRowsForPaneFn: | ((paneKey: string) => AgentStatusIpcPayload[]) | null private readonly attestAgentHookCompatibilityAuthorityFn: | ((candidate: { paneKey: string launchTokenHash: string connectionId: string | null terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null) | null private readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null private readonly canRecoverPersistentLocalPtysFn: () => boolean private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private readonly prepareAiVaultSessionResumeFn: | ((args: AiVaultPrepareSessionResumeArgs) => Promise) | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() private readonly orchestrationCompatibilitySshAttachments = new Map< string, OrchestrationCompatibilitySshAttachmentAuthority >() private sshRelayRecoveryGenerationByTargetId = new Map() private legacyWorkerTerminalRecoveryQueue: Promise = Promise.resolve() private legacyWorkerTerminalRecoveryRetries = new Map< string, { attempt: number connectionId?: string materializeRenderer: boolean timer: ReturnType | null } >() private legacyWorkerTerminalReceiptEpochByPane = new Map() private legacyWorkerRecoveredPtys = new Set() private restoredOrchestrationAuthorityByPtyId = new Map< string, RestoredOrchestrationAuthorityReceipt >() private ptyControllerInventorySequence = 0 private ptyControllerAggregateInventoryGeneration = 0 private ptyControllerInventoryGenerationByProvider = new Map() private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null private readonly claudeAgentTeams = new ClaudeAgentTeamsService() private mobileDictation: { id: string owner: string clientId?: string connectionId?: string state: 'starting' | 'active' | 'closing' partialText: string finalTexts: string[] errors: string[] } | null = null constructor( store: RuntimeStore | null = null, stats?: StatsCollector, deps?: { getLocalProvider?: () => IPtyProvider getSshProvider?: (connectionId: string) => IPtyProvider | undefined onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void // Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` * filters out so they can't read as running agents. Mobile native chat needs * them: for an agent that publishes identity separately (Pi), that row is the * only carrier of the provider session a transcript is addressed by. */ getAgentProviderSessionSnapshot?: () => AgentStatusIpcPayload[] getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[] attestAgentHookCompatibilityAuthority?: (candidate: { paneKey: string launchTokenHash: string connectionId: string | null terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void canRecoverPersistentLocalPtys?: () => boolean // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never // runs under `orca serve`, so remote/SSH hosts would silently drop // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] prepareAiVaultSessionResume?: ( args: AiVaultPrepareSessionResumeArgs ) => Promise buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport } ) { this.store = store // Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return. const persistedClientTabSelections = store?.getMobileClientTabSelections?.() if (persistedClientTabSelections) { this.clientSessionTabSelections.hydrate(persistedClientTabSelections) } this.clientSessionTabSelections.setPersistListener((state) => { this.store?.setMobileClientTabSelections?.(state) }) this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null this.getAgentProviderSessionSnapshotFn = deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null this.attestAgentHookCompatibilityAuthorityFn = deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions // even on headless `orca serve` hosts where registerCoreHandlers never runs. if (deps?.getAdditionalAiVaultCodexHomePaths) { configureAiVaultSessionSources({ getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths }) } // Why: the daemon adapter is installed via `setLocalPtyProvider()` during // attachMainWindowServices, AFTER this service is constructed. Capturing // `getLocalPtyProvider()` at construction time would freeze a reference to // the pre-daemon `LocalPtyProvider` and miss the routed adapter. Resolve // lazily via thunk so teardown always sees the currently-installed // provider (design §4.3 wire-up). this.getLocalProviderFn = deps?.getLocalProvider ?? null this.getSshProviderFn = deps?.getSshProvider ?? null this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null this.agentSessionClaimSigner = deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here // (terminal-query-authority.md §ConPTY DA1). registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) // Why: a renderer attribute push must reach already-live emulators too — // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color // override reset a theme apply implies (terminal-query-authority.md // §View-attribute bridge). registerTerminalViewAttributesApplier((attributes) => { for (const state of this.headlessTerminals.values()) { state.emulator.applyPushedViewAttributes(attributes) } }) } getLocalProvider(): IPtyProvider | null { return this.getLocalProviderFn ? this.getLocalProviderFn() : null } private async stopPtysForDestructiveWorktreeRemoval( worktreeId: string, options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} ): Promise { const { connectionId, allowUnverifiedStop } = options const provider = connectionId ? this.getSshProviderFn?.(connectionId) : this.getLocalProvider() if (!provider) { throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) } const teardownResult = await killAllProcessesForWorktree(worktreeId, { runtime: this, // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id // workspace's terminals on another connection (mirrors the IPC removal path). resolvedWorktreeId: worktreeId, ...(connectionId ? { resolvedConnectionId: connectionId } : {}), localProvider: provider, onPtyStopped: this.onPtyStopped ?? undefined, requirePhysicalStop: true, // Why (#11960): set only by an explicit Force Delete, never by the ordinary // confirmation — otherwise the gate would be off on the primary delete path. ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), ...(connectionId ? { includeLocalRegistry: false } : {}) }) const total = teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped if (total > 0) { console.info( `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` ) } } getStatsSummary(): StatsSummary | null { return this.stats?.getSummary() ?? null } getMemorySnapshot(): Promise { if (!this.store) { throw new Error('runtime_unavailable') } return collectMemorySnapshot(this.store) } getUIState(): PersistedUIState { if (!this.store?.getUI) { throw new Error('runtime_unavailable') } return this.store.getUI() } updateUIState(updates: Partial): PersistedUIState { if (!this.store?.getUI || !this.store.updateUI) { throw new Error('runtime_unavailable') } this.store.updateUI(updates) return this.store.getUI() } recordFeatureInteraction(id: FeatureInteractionId): PersistedUIState { if (!this.store?.recordFeatureInteraction) { throw new Error('runtime_unavailable') } return this.store.recordFeatureInteraction(id) } getClientSettings(): Pick< GlobalSettings, | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' | 'defaultRepoSelection' | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' | 'prBotAuthorOverrides' > { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') } const settings = this.store.getSettings() return { defaultTuiAgent: settings.defaultTuiAgent ?? null, disabledTuiAgents: settings.disabledTuiAgents ?? [], agentCmdOverrides: settings.agentCmdOverrides ?? {}, agentDefaultArgs: settings.agentDefaultArgs ?? {}, agentDefaultEnv: settings.agentDefaultEnv ?? {}, agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, defaultTaskSource: settings.defaultTaskSource ?? 'github', defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? [...TASK_PROVIDERS], defaultRepoSelection: settings.defaultRepoSelection ?? null, defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null, githubProjects: settings.githubProjects, experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true, compactWorktreeCards: settings.compactWorktreeCards === true, minimaxGroupId: settings.minimaxGroupId ?? '', minimaxUsageModels: settings.minimaxUsageModels ?? 'general', prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [] } } private reconcileManagedAgentHooks(): Promise { const generation = ++this.managedHookReconciliationGeneration const reconciliation = this.managedHookReconciliationTail.then(async () => { if (generation !== this.managedHookReconciliationGeneration) { return } const settings = this.store?.getSettings() if (!settings) { return } await applyAgentStatusHooksEnabled(settings.agentStatusHooksEnabled !== false, settings, { shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32', onInstallError: recordManagedHookInstallFailure, shouldContinue: (agent) => { const current = this.store?.getSettings() return ( current !== undefined && current.agentStatusHooksEnabled !== false && !current.disabledTuiAgents?.includes(agent) ) } }) }) this.managedHookReconciliationTail = reconciliation.catch(() => {}) return reconciliation } async updateClientSettings( updates: Pick< Partial, | 'agentStatusHooksEnabled' | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentDefaultArgs' | 'agentDefaultEnv' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' | 'defaultRepoSelection' | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' | 'prBotAuthorOverrides' > ): Promise< Pick< GlobalSettings, | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' | 'defaultRepoSelection' | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' | 'prBotAuthorOverrides' > > { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') } const beforeSettings = this.store.getSettings() const before = beforeSettings.agentStatusHooksEnabled !== false this.store.updateSettings(updates, { notifyListeners: true }) const settings = this.store.getSettings() if ( (typeof updates.agentStatusHooksEnabled === 'boolean' && before !== updates.agentStatusHooksEnabled) || (updates.disabledTuiAgents !== undefined && !haveSameDisabledTuiAgents(beforeSettings.disabledTuiAgents, settings.disabledTuiAgents)) ) { await this.reconcileManagedAgentHooks() } return this.getClientSettings() } getClientTerminalQuickCommands(): TerminalQuickCommand[] { if (!this.store?.getSettings) { throw new Error('runtime_unavailable') } return this.store.getSettings().terminalQuickCommands ?? [] } updateClientTerminalQuickCommands( mutation: TerminalQuickCommandMutation ): TerminalQuickCommand[] { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') } const current = this.getClientTerminalQuickCommands() if ( mutation.type === 'upsert' && !current.some((command) => command.id === mutation.command.id) && current.length >= MAX_QUICK_COMMANDS ) { throw new Error('Quick command limit reached') } const next = applyTerminalQuickCommandMutation(current, mutation) this.store.updateSettings({ terminalQuickCommands: next }, { notifyListeners: true }) return this.getClientTerminalQuickCommands() } updateClientPRBotAuthorOverride(args: { author: string; isBot: boolean }) { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') } const current = this.store.getSettings().prBotAuthorOverrides this.store.updateSettings( { prBotAuthorOverrides: applyPRBotAuthorOverride(current, args.author, args.isBot) }, { notifyListeners: true } ) return this.getClientSettings() } listAutomations(): Automation[] { if (!this.store?.listAutomations) { throw new Error('runtime_unavailable') } return this.store.listAutomations() } listAutomationRuns(automationId?: string): AutomationRun[] { if (!this.store?.listAutomationRuns) { throw new Error('runtime_unavailable') } return this.store.listAutomationRuns(automationId) } showAutomation(id: string): Automation { const automation = this.listAutomations().find((entry) => entry.id === id) if (!automation) { throw new Error('Automation not found.') } return automation } async createAutomation(input: RuntimeAutomationCreateInput): Promise { if (!this.store?.createAutomation) { throw new Error('runtime_unavailable') } const target = await this.resolveAutomationTarget(input) if (input.reuseSession && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } return this.store.createAutomation({ name: input.name, prompt: input.prompt, precheck: input.precheck, agentId: input.agentId, runContext: input.runContext, sourceContext: input.sourceContext, projectId: target.projectId, workspaceMode: target.workspaceMode, workspaceId: target.workspaceId, baseBranch: input.baseBranch, setupDecision: input.setupDecision, reuseSession: input.reuseSession, timezone: input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, rrule: input.rrule, dtstart: input.dtstart, enabled: input.enabled, missedRunGraceMinutes: input.missedRunGraceMinutes }) } async updateAutomation(id: string, updates: RuntimeAutomationUpdateInput): Promise { if (!this.store?.updateAutomation) { throw new Error('runtime_unavailable') } const current = this.showAutomation(id) const patch: AutomationUpdateInput = {} if (hasRuntimeAutomationUpdateValue(updates, 'name')) { patch.name = updates.name } if (hasRuntimeAutomationUpdateValue(updates, 'prompt')) { patch.prompt = updates.prompt } if (hasRuntimeAutomationUpdateValue(updates, 'precheck')) { patch.precheck = updates.precheck } if (hasRuntimeAutomationUpdateValue(updates, 'agentId')) { patch.agentId = updates.agentId } if (hasRuntimeAutomationUpdateValue(updates, 'runContext')) { patch.runContext = updates.runContext } if (hasRuntimeAutomationUpdateValue(updates, 'sourceContext')) { patch.sourceContext = updates.sourceContext } if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) { patch.baseBranch = updates.baseBranch } if (hasRuntimeAutomationUpdateValue(updates, 'setupDecision')) { patch.setupDecision = updates.setupDecision } if (hasRuntimeAutomationUpdateValue(updates, 'reuseSession')) { patch.reuseSession = updates.reuseSession } if (hasRuntimeAutomationUpdateValue(updates, 'timezone')) { patch.timezone = updates.timezone } if (hasRuntimeAutomationUpdateValue(updates, 'rrule')) { patch.rrule = updates.rrule } if (hasRuntimeAutomationUpdateValue(updates, 'dtstart')) { patch.dtstart = updates.dtstart } if (hasRuntimeAutomationUpdateValue(updates, 'enabled')) { patch.enabled = updates.enabled } if (hasRuntimeAutomationUpdateValue(updates, 'missedRunGraceMinutes')) { patch.missedRunGraceMinutes = updates.missedRunGraceMinutes } const targetChanged = hasRuntimeAutomationUpdateValue(updates, 'repo') || hasRuntimeAutomationUpdateValue(updates, 'workspace') || hasRuntimeAutomationUpdateValue(updates, 'workspaceMode') if (targetChanged) { const target = await this.resolveAutomationTarget(updates, current) if (patch.reuseSession === true && target.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } patch.projectId = target.projectId patch.workspaceMode = target.workspaceMode patch.workspaceId = target.workspaceId if (target.workspaceMode !== 'existing') { patch.reuseSession = false } } if (!targetChanged && patch.reuseSession && current.workspaceMode !== 'existing') { throw new Error('Session reuse requires an existing workspace target.') } return this.store.updateAutomation(id, patch) } deleteAutomation(id: string): { removed: boolean; id: string } { if (!this.store?.deleteAutomation) { throw new Error('runtime_unavailable') } this.showAutomation(id) this.store.deleteAutomation(id) return { removed: true, id } } async runAutomationNow(id: string): Promise { if (!this.automationService) { throw new Error('runtime_unavailable') } return await this.automationService.runNow(id) } private async resolveAutomationTarget( input: { repo?: string workspace?: string workspaceMode?: AutomationWorkspaceMode baseBranch?: string | null }, current?: Automation ): Promise<{ projectId: string workspaceMode: AutomationWorkspaceMode workspaceId?: string | null }> { const hasRepo = input.repo !== undefined const hasWorkspace = input.workspace !== undefined if ( current?.workspaceMode === 'existing' && hasRepo && !hasWorkspace && input.workspaceMode !== 'new_per_run' ) { throw new Error( 'Repo updates for existing-workspace automation require workspaceMode new_per_run.' ) } const workspace = input.workspace ? await this.showManagedWorktree(input.workspace) : null const repo = input.repo ? await this.showRepo(input.repo) : null const workspaceMode = input.workspaceMode ?? (workspace ? 'existing' : input.repo && !current ? 'new_per_run' : (current?.workspaceMode ?? 'new_per_run')) if (workspaceMode === 'existing') { const workspaceId = workspace?.id ?? current?.workspaceId const projectId = workspace?.repoId ?? current?.projectId if (repo && repo.id !== projectId) { throw new Error('Selected workspace belongs to a different repo.') } if (!workspaceId || !projectId) { throw new Error('Existing-workspace automation requires --workspace.') } return { projectId, workspaceMode, workspaceId } } const projectId = repo?.id ?? workspace?.repoId ?? current?.projectId if (!projectId) { throw new Error('Automation requires --repo or --workspace.') } return { projectId, workspaceMode: 'new_per_run', workspaceId: null } } // Why: lazy initialization — the DB path depends on Electron's userData // which may not be finalized until after app.ready. Also allows unit tests // to inject an in-memory DB without touching the filesystem. getOrchestrationDb(): OrchestrationDb { if (!this._orchestrationDb) { const { app } = require('electron') const dbPath = join(app.getPath('userData'), 'orchestration.db') this._orchestrationDb = new OrchestrationDb(dbPath) } return this._orchestrationDb } setOrchestrationDb(db: OrchestrationDb): void { this._orchestrationDb = db } private getLegacyWorkerTerminalRecoveryPlan(): LegacyWorkerTerminalRecoveryPlan { try { return planLegacyWorkerTerminalRecovery( this.getOrchestrationDb().listLegacyWorkerTerminalRecoveryRows() ) } catch (error) { console.warn('[orchestration] failed to plan legacy worker terminal recovery', error) return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } } } prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan { const plan = this.getLegacyWorkerTerminalRecoveryPlan() const store = this.store if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.flushOrThrow) { return plan } const sessions = new Map< ExecutionHostId, { current: WorkspaceSessionState; next: WorkspaceSessionState } >() const changedHostIds = new Set() for (const blocked of plan.blockedPanes) { let hostIds: ExecutionHostId[] try { hostIds = [this.getWorkspaceSessionHostIdForWorktree(blocked.worktreeId)] } catch (error) { console.warn('[orchestration] legacy worker resume fence owner is unavailable', { worktreeId: blocked.worktreeId, error }) hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] } for (const hostId of hostIds) { let state = sessions.get(hostId) if (!state) { const current = store.getWorkspaceSession(hostId) if (!current) { continue } state = { current, next: structuredClone(current) } sessions.set(hostId, state) } const record = state.next.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] if ( !record || !runtimeWorktreeIdsEqual(record.worktreeId, blocked.worktreeId) || record.automaticResumeBlockedBy === 'legacy-orchestration-worker' ) { continue } state.next.sleepingAgentSessionsByPaneKey = { ...state.next.sleepingAgentSessionsByPaneKey, [blocked.paneKey]: { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' } } changedHostIds.add(hostId) } } const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) if (changed.length === 0) { return plan } try { for (const [hostId, state] of changed) { store.setWorkspaceSession(state.next, hostId) } store.flushOrThrow() } catch (error) { console.warn('[orchestration] failed to persist legacy worker resume fence', error) } return plan } async reconcileLegacyWorkerTerminals( options: { connectionId?: string; materializeRenderer?: boolean } = {} ): Promise { let resolveResult!: (result: LegacyWorkerTerminalRecoveryResult) => void let rejectResult!: (error: unknown) => void const result = new Promise((resolve, reject) => { resolveResult = resolve rejectResult = reject }) const run = this.legacyWorkerTerminalRecoveryQueue.then(async () => { try { resolveResult(await this.reconcileLegacyWorkerTerminalsNow(options)) } catch (error) { rejectResult(error) } }) this.legacyWorkerTerminalRecoveryQueue = run.catch(() => undefined) return result } async refreshRestoredOrchestrationAuthority(connectionId: string | null = null): Promise { if (connectionId === null && !this.canRecoverPersistentLocalPtysFn()) { return } const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( [...(await this.getResolvedWorktreeMap()).values()], null, undefined, connectionId ) if (!inventory) { throw new Error('terminal_liveness_unavailable') } } private hasExactTerminalSurfaceIdentity(expected: { worktreeId: string tabId: string leafId: string ptyId: string terminalHandle: string incarnationId: string }): boolean { if (this.graphStatus !== 'ready') { return false } const pty = this.ptysById.get(expected.ptyId) if ( !pty?.connected || pty.incarnationId !== expected.incarnationId || pty.tabId !== expected.tabId || pty.paneKey !== makePaneKey(expected.tabId, expected.leafId) || !runtimeWorktreeIdsEqual(pty.worktreeId, expected.worktreeId) || this.handleByPtyId.get(expected.ptyId) !== expected.terminalHandle ) { return false } const tab = this.tabs.get(expected.tabId) const leaf = this.leaves.get(this.getLeafKey(expected.tabId, expected.leafId)) const ptyLeaves = this.getLeavesForPty(expected.ptyId) return ( Boolean(tab && runtimeWorktreeIdsEqual(tab.worktreeId, expected.worktreeId)) && Boolean( leaf && leaf.ptyId === expected.ptyId && runtimeWorktreeIdsEqual(leaf.worktreeId, expected.worktreeId) ) && ptyLeaves.length === 1 && ptyLeaves[0]?.tabId === expected.tabId && ptyLeaves[0]?.leafId === expected.leafId ) } private hasExactPersistedTerminalSurfaceIdentity(expected: { worktreeId: string tabId: string leafId: string ptyId: string incarnationId: string }): boolean { const session = this.getWorkspaceSessionForWorktree(expected.worktreeId) const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, expected.worktreeId) : null if (!session || !sessionWorktreeId) { return false } const tab = session.tabsByWorktree[sessionWorktreeId]?.find( (candidate) => candidate.id === expected.tabId ) const paneKey = makePaneKey(expected.tabId, expected.leafId) return Boolean( tab && session.terminalLayoutsByTabId[expected.tabId]?.ptyIdsByLeafId?.[expected.leafId] === expected.ptyId && session.terminalPtyIncarnationsByPaneKey?.[paneKey] === expected.incarnationId ) } private persistLegacyWorkerTerminalRecoveryResolution( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number], resolution: 'adopted' | 'exited' ): boolean { const store = this.store const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { return false } const record = session.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] if (!record || !runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { return true } const next = structuredClone(session) delete next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] try { this.setWorkspaceSessionForWorktree(candidate.worktreeId, next) store.flushOrThrow() return true } catch (error) { this.setWorkspaceSessionForWorktree(candidate.worktreeId, session) console.warn('[orchestration] failed to persist legacy worker recovery resolution', { dispatchId: candidate.dispatchId, resolution, error }) return false } } private reconcileMissingLegacyWorkerTerminal( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] ): boolean { if (candidate.dispatchStatus !== 'pending' && candidate.dispatchStatus !== 'dispatched') { return true } try { this.getOrchestrationDb().reconcileMissingWorkerTerminal( candidate.dispatchId, 'The assigned worker terminal is no longer live after orchestration recovery.' ) return true } catch (error) { console.warn('[orchestration] failed to reconcile missing worker terminal', { dispatchId: candidate.dispatchId, error }) return false } } private resolveExitedLegacyWorkerTerminal( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] ): boolean { if ( !this.rollbackLegacyWorkerTerminalSurface(candidate) || !this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'exited') || !this.reconcileMissingLegacyWorkerTerminal(candidate) ) { return false } this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') return true } private rollbackLegacyWorkerTerminalSurface( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] ): boolean { const store = this.store const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) if (store?.setWorkspaceSession && store.flushOrThrow && session) { const retired = retireTerminalSurfaceFromPersistence(session, { worktreeId: candidate.worktreeId, parentTabId: candidate.tabId, leafId: candidate.leafId, ptyId: candidate.ptyId, incarnationId: candidate.incarnationId }) if (retired !== session) { try { this.setWorkspaceSessionForWorktree(candidate.worktreeId, retired) store.flushOrThrow() } catch (error) { this.setWorkspaceSessionForWorktree(candidate.worktreeId, session) console.warn('[orchestration] failed to persist legacy worker surface rollback', { dispatchId: candidate.dispatchId, error }) return false } } } const snapshot = this.mobileSessionTabsByWorktree.get(candidate.worktreeId) if (snapshot) { const retired = retireTerminalSurfacesFromSnapshot({ snapshot, ptyId: candidate.ptyId, exactSurfaces: [{ parentTabId: candidate.tabId, leafId: candidate.leafId }], exactOnly: true }) if (retired) { this.mobileSessionTabsByWorktree.set(candidate.worktreeId, retired.snapshot) this.notifyMobileSessionTabsChanged(candidate.worktreeId) } } const leafKey = this.getLeafKey(candidate.tabId, candidate.leafId) const leaf = this.leaves.get(leafKey) const pty = this.ptysById.get(candidate.ptyId) if ( leaf?.ptyId === candidate.ptyId && runtimeWorktreeIdsEqual(leaf.worktreeId, candidate.worktreeId) ) { this.leaves.delete(leafKey) const surfaceHandle = this.handleByLeafKey.get(leafKey) this.handleByLeafKey.delete(leafKey) const handleRecord = surfaceHandle ? this.handles.get(surfaceHandle) : undefined if ( surfaceHandle && handleRecord?.tabId === candidate.tabId && handleRecord.leafId === candidate.leafId && handleRecord.ptyId === candidate.ptyId ) { this.handles.delete(surfaceHandle) } this.rebuildLeafPtyIndex() if (![...this.leaves.values()].some((entry) => entry.tabId === candidate.tabId)) { this.tabs.delete(candidate.tabId) } } if (pty?.tabId === candidate.tabId) { pty.tabId = null pty.paneKey = null } this.notifier?.resolveLegacyWorkerTerminalRecovery?.( candidate.paneKey, 'rolled_back', candidate.ptyId ) return true } private resolveReplacedLegacyWorkerTerminal( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] ): boolean { return this.resolveExitedLegacyWorkerTerminal(candidate) } private updateLegacyWorkerTerminalRecoveryRetry( plan: LegacyWorkerTerminalRecoveryPlan, deferredDispatchIds: ReadonlySet, options: { connectionId?: string; materializeRenderer?: boolean } ): void { const scopeKey = options.connectionId ? `ssh:${options.connectionId}` : 'local' const hasDeferredWorker = plan.candidates.some((candidate) => { const sshPty = parseAppSshPtyId(candidate.ptyId) const inScope = options.connectionId ? sshPty?.connectionId === options.connectionId : sshPty === null return inScope && deferredDispatchIds.has(candidate.dispatchId) }) if (!hasDeferredWorker) { this.cancelLegacyWorkerTerminalRecoveryRetry(scopeKey) return } const existing = this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) const retry = existing ?? { attempt: 0, ...(options.connectionId ? { connectionId: options.connectionId } : {}), materializeRenderer: options.materializeRenderer === true, timer: null } retry.materializeRenderer ||= options.materializeRenderer === true this.legacyWorkerTerminalRecoveryRetries.set(scopeKey, retry) this.armLegacyWorkerTerminalRecoveryRetry(scopeKey, retry) } private cancelLegacyWorkerTerminalRecoveryRetry(scopeKey: string): void { const retry = this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) if (retry?.timer) { clearTimeout(retry.timer) } this.legacyWorkerTerminalRecoveryRetries.delete(scopeKey) } private armLegacyWorkerTerminalRecoveryRetry( scopeKey: string, retry: { attempt: number connectionId?: string materializeRenderer: boolean timer: ReturnType | null } ): void { if (retry.timer) { return } const delayMs = Math.min(1_000 * 2 ** retry.attempt, 30_000) retry.attempt += 1 retry.timer = setTimeout(() => { retry.timer = null void this.reconcileLegacyWorkerTerminals({ ...(retry.connectionId ? { connectionId: retry.connectionId } : {}), materializeRenderer: retry.materializeRenderer }).catch((error) => { console.warn('[orchestration] worker terminal recovery retry failed', { scope: scopeKey, error }) if (this.legacyWorkerTerminalRecoveryRetries.get(scopeKey) === retry) { this.armLegacyWorkerTerminalRecoveryRetry(scopeKey, retry) } }) }, delayMs) retry.timer.unref?.() } private async reconcileLegacyWorkerTerminalsNow(options: { connectionId?: string materializeRenderer?: boolean }): Promise { const plan = this.prepareLegacyWorkerTerminalRecovery() const adoptedDispatchIds: string[] = [] const exitedDispatchIds: string[] = [] const deferredDispatchIds = new Set(plan.ambiguousDispatchIds) const recoveryCandidatesByProvider = new Map< string, { connectionId: string | null entries: { candidate: (typeof plan.candidates)[number] workspace: TerminalWorkspaceLaunchScope resolvedWorkspace: ResolvedWorktree }[] } >() for (const candidate of plan.candidates) { try { const workspace = await this.resolveTerminalWorkspaceLaunchScope( `id:${candidate.worktreeId}` ) const sshPty = parseAppSshPtyId(candidate.ptyId) if (workspace.connectionId) { if ( options.connectionId !== workspace.connectionId || sshPty?.connectionId !== workspace.connectionId ) { deferredDispatchIds.add(candidate.dispatchId) continue } } else if ( options.connectionId !== undefined || sshPty !== null || !this.canRecoverPersistentLocalPtysFn() ) { deferredDispatchIds.add(candidate.dispatchId) continue } const resolvedWorkspace = workspace.folderWorkspace ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) : await this.resolveWorktreeSelector(`id:${workspace.id}`) const connectionId = workspace.connectionId ?? null const providerKey = connectionId === null ? 'local' : `ssh:${connectionId}` const provider = recoveryCandidatesByProvider.get(providerKey) ?? { connectionId, entries: [] } provider.entries.push({ candidate, workspace, resolvedWorkspace }) recoveryCandidatesByProvider.set(providerKey, provider) } catch { deferredDispatchIds.add(candidate.dispatchId) } } for (const provider of recoveryCandidatesByProvider.values()) { const resolvedWorktrees = [ ...new Map( provider.entries.map(({ resolvedWorkspace }) => [resolvedWorkspace.id, resolvedWorkspace]) ).values() ] const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, null, undefined, provider.connectionId ) if (!inventory) { provider.entries.forEach(({ candidate }) => deferredDispatchIds.add(candidate.dispatchId)) continue } for (const { candidate, workspace } of provider.entries) { if (!inventory.livePtyIds.has(candidate.ptyId)) { if (this.resolveExitedLegacyWorkerTerminal(candidate)) { exitedDispatchIds.push(candidate.dispatchId) } else { deferredDispatchIds.add(candidate.dispatchId) } continue } const controllerIdentity = inventory.terminalIdentityByPtyId.get(candidate.ptyId) if (!controllerIdentity) { deferredDispatchIds.add(candidate.dispatchId) continue } if ( controllerIdentity.handle !== candidate.terminalHandle || controllerIdentity.incarnationId !== candidate.incarnationId ) { if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { exitedDispatchIds.push(candidate.dispatchId) } else { deferredDispatchIds.add(candidate.dispatchId) } continue } const preAdoptionInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, null, undefined, provider.connectionId ) if (!preAdoptionInventory) { deferredDispatchIds.add(candidate.dispatchId) continue } if (!preAdoptionInventory.livePtyIds.has(candidate.ptyId)) { if (this.resolveExitedLegacyWorkerTerminal(candidate)) { exitedDispatchIds.push(candidate.dispatchId) } else { deferredDispatchIds.add(candidate.dispatchId) } continue } const preAdoptionIdentity = preAdoptionInventory.terminalIdentityByPtyId.get( candidate.ptyId ) if (!preAdoptionIdentity) { deferredDispatchIds.add(candidate.dispatchId) continue } if ( preAdoptionIdentity.handle !== candidate.terminalHandle || preAdoptionIdentity.incarnationId !== candidate.incarnationId ) { if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { exitedDispatchIds.push(candidate.dispatchId) } else { deferredDispatchIds.add(candidate.dispatchId) } continue } const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) const sessionWorktreeId = session ? resolveTerminalSessionWorktreeId(session, candidate.worktreeId) : null const activeTabId = sessionWorktreeId ? session?.activeTabIdByWorktree?.[sessionWorktreeId] : undefined const activeGroupId = sessionWorktreeId ? session?.activeGroupIdByWorktree?.[sessionWorktreeId] : undefined const exactSurfaceAlreadyPublished = this.hasExactPersistedTerminalSurfaceIdentity(candidate) && this.hasExactTerminalSurfaceIdentity(candidate) if (!exactSurfaceAlreadyPublished) { try { await this.adoptTerminalOrphansFromInventory( { worktree: `id:${candidate.worktreeId}`, expectedTopologyRevision: this.getTerminalTopologyRevision(candidate.worktreeId), ...(activeTabId ? { activeTabId } : {}), ...(activeGroupId ? { activeGroupId } : {}), claims: [ { terminal: candidate.terminalHandle, ptyId: candidate.ptyId, incarnationId: candidate.incarnationId, tabId: candidate.tabId, leafId: candidate.leafId } ] }, workspace, preAdoptionInventory ) } catch (error) { console.warn('[orchestration] legacy worker terminal adoption deferred', { dispatchId: candidate.dispatchId, error }) deferredDispatchIds.add(candidate.dispatchId) continue } } let rendererMaterialized = options.materializeRenderer !== true || this.legacyWorkerTerminalReceiptEpochByPane.get(candidate.paneKey) === this.rendererGraphEpoch const pty = this.ptysById.get(candidate.ptyId) if ( options.materializeRenderer && !rendererMaterialized && pty && this.notifier?.revealTerminalSession ) { for (let attempt = 0; attempt < 2 && !rendererMaterialized; attempt += 1) { try { const reveal = await this.notifier.revealTerminalSession(candidate.worktreeId, { ptyId: candidate.ptyId, title: getLatestPtyTitle(pty) ?? pty.controllerTitle, activate: false, presentation: 'background', tabId: candidate.tabId, leafId: candidate.leafId, focus: false, expectedProcessIdentity: { terminalHandle: candidate.terminalHandle, incarnationId: candidate.incarnationId } }) const identity = reveal?.identity if ( !identity || !runtimeWorktreeIdsEqual(identity.worktreeId, candidate.worktreeId) || identity.tabId !== candidate.tabId || identity.leafId !== candidate.leafId || identity.ptyId !== candidate.ptyId ) { throw new Error('terminal_reveal_identity_mismatch') } rendererMaterialized = true this.legacyWorkerTerminalReceiptEpochByPane.set( candidate.paneKey, this.rendererGraphEpoch ) } catch (error) { if (attempt === 0) { await new Promise((resolve) => setTimeout(resolve, 100)) continue } console.warn('[orchestration] adopted legacy worker was not revealed', { dispatchId: candidate.dispatchId, error }) } } } if (!rendererMaterialized) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) deferredDispatchIds.add(candidate.dispatchId) continue } if ( options.materializeRenderer === true && !this.hasExactTerminalSurfaceIdentity({ worktreeId: candidate.worktreeId, tabId: candidate.tabId, leafId: candidate.leafId, ptyId: candidate.ptyId, terminalHandle: candidate.terminalHandle, incarnationId: candidate.incarnationId }) ) { deferredDispatchIds.add(candidate.dispatchId) continue } const finalInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, null, undefined, provider.connectionId ) if (!finalInventory) { deferredDispatchIds.add(candidate.dispatchId) continue } if (!finalInventory.livePtyIds.has(candidate.ptyId)) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) this.onPtyExit(candidate.ptyId, 0, candidate.incarnationId) if (this.resolveExitedLegacyWorkerTerminal(candidate)) { exitedDispatchIds.push(candidate.dispatchId) } else { deferredDispatchIds.add(candidate.dispatchId) } continue } const finalIdentity = finalInventory.terminalIdentityByPtyId.get(candidate.ptyId) if (!finalIdentity) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) deferredDispatchIds.add(candidate.dispatchId) continue } if ( finalIdentity.handle !== candidate.terminalHandle || finalIdentity.incarnationId !== candidate.incarnationId ) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { exitedDispatchIds.push(candidate.dispatchId) } else { deferredDispatchIds.add(candidate.dispatchId) } continue } if (!this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'adopted')) { deferredDispatchIds.add(candidate.dispatchId) continue } this.legacyWorkerRecoveredPtys.add(candidate.ptyId) this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'adopted') adoptedDispatchIds.push(candidate.dispatchId) } } const result = { blockedPaneCount: plan.blockedPanes.length, adoptedDispatchIds, exitedDispatchIds, deferredDispatchIds: [...deferredDispatchIds] } this.updateLegacyWorkerTerminalRecoveryRetry(plan, deferredDispatchIds, options) // Why: previously requested releases may only finish after the owning provider's terminals // are rediscovered; this pass runs per scope (local and each reconnected provider). void reconcileRequestedWorkerTerminalReleases(this).catch((error) => { console.warn('[orchestration] worker terminal release reconciliation failed', { error }) }) return result } setAutomationService(service: AutomationService): void { this.automationService = service } getRuntimeId(): string { return this.runtimeId } resolveOrchestrationWorkerServer(selector: string): OrchestrationWorkerServer { if (!this.orchestrationEnvironmentTransport) { throw new OrchestrationError( 'server_required', 'Connected-server orchestration is unavailable in this runtime.' ) } return this.orchestrationEnvironmentTransport.resolve(selector) } async callOrchestrationWorkerServer( selector: string, method: string, params: unknown, timeoutMs?: number, envelope?: RuntimeOrchestrationEnvelope ): Promise { if (!this.orchestrationEnvironmentTransport) { throw new OrchestrationError( 'server_required', 'Connected-server orchestration is unavailable in this runtime.' ) } if (isOrchestrationMutation(method, params)) { const statusResponse = await this.orchestrationEnvironmentTransport.call( selector, 'status.get', undefined, timeoutMs ) if (statusResponse.ok === false) { throw new OrchestrationError( statusResponse.error.code, statusResponse.error.message, statusResponse.error.data ) } const status = statusResponse.result as RuntimeStatus if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { throw new OrchestrationError( 'orchestration_migration_required', 'The connected worker server does not support the current orchestration contract. No effects were applied.', orchestrationMigrationData('runtime_capability_missing') ) } } const response = await this.orchestrationEnvironmentTransport.call( selector, method, params, timeoutMs, method.startsWith('orchestration.') ? { ...envelope, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION } : envelope ) if (response.ok === false) { throw new OrchestrationError(response.error.code, response.error.message, response.error.data) } return response.result } async syncOrchestrationFederation(runId?: string): Promise { if (!this.orchestrationEnvironmentTransport) { return } const dispatches = this.getOrchestrationDb().listActiveFederatedDispatches(runId) await Promise.allSettled( dispatches.map((dispatch) => this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id)) ) } private syncOrchestrationFederatedDispatch(dispatchId: string): Promise { const current = this.orchestrationFederationSyncs.get(dispatchId) if (current) { return current } const sync = syncFederatedDispatch(this, dispatchId) .then(() => { this.orchestrationFederationWarnings.delete(dispatchId) }) .catch((error: unknown) => { if (!this.orchestrationFederationWarnings.has(dispatchId)) { console.warn(`[orchestration] Federation sync failed for ${dispatchId}:`, error) this.orchestrationFederationWarnings.add(dispatchId) } throw error }) .finally(() => { this.orchestrationFederationSyncs.delete(dispatchId) }) this.orchestrationFederationSyncs.set(dispatchId, sync) return sync } ensureOrchestrationFederationRelay(runId?: string): void { if (!this.orchestrationEnvironmentTransport) { return } for (const dispatch of this.getOrchestrationDb().listActiveFederatedDispatches(runId)) { if (this.orchestrationFederationTimers.has(dispatch.dispatch_id)) { continue } const tick = () => { const worker = this.getOrchestrationDb().getWorkerDispatch(dispatch.dispatch_id) if (!worker || !['starting', 'ready', 'stopping'].includes(worker.state)) { const activeTimer = this.orchestrationFederationTimers.get(dispatch.dispatch_id) if (activeTimer) { clearInterval(activeTimer) } this.orchestrationFederationTimers.delete(dispatch.dispatch_id) this.orchestrationFederationWarnings.delete(dispatch.dispatch_id) return } void this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id).catch(() => undefined) } const timer = setInterval(tick, 1_000) timer.unref?.() this.orchestrationFederationTimers.set(dispatch.dispatch_id, timer) tick() } } stopOrchestrationFederationRelay(): void { for (const timer of this.orchestrationFederationTimers.values()) { clearInterval(timer) } this.orchestrationFederationTimers.clear() this.orchestrationFederationWarnings.clear() } getStartedAt(): number { return this.startedAt } private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null { const scope = parseWorkspaceKey(worktreeId) if (scope?.type === 'folder') { const workspace = this.store ?.getFolderWorkspaces?.() .find((entry) => entry.id === scope.folderWorkspaceId) if (!workspace) { return null } const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID } const resolvedWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId const repo = this.store?.getRepo?.(getRepoIdFromWorktreeId(resolvedWorktreeId)) return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID } private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId { const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) if (!hostId) { throw new Error('folder_workspace_not_found') } return hostId } private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null } private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void { this.store?.setWorkspaceSession?.( session, this.getWorkspaceSessionHostIdForWorktree(worktreeId) ) } private getKnownWorkspaceSessionWorktreeIds(): Set { const repos = this.store?.getRepos?.() ?? [] const repoIds = new Set(repos.map((repo) => repo.id)) const hostIds = new Set(['local']) for (const repo of repos) { hostIds.add(getRepoExecutionHostId(repo)) } const worktreeIds = new Set() for (const hostId of hostIds) { const session = this.store?.getWorkspaceSession?.(hostId) for (const worktreeId of Object.keys(session?.tabsByWorktree ?? {})) { if (repoIds.has(getRepoIdFromWorktreeId(worktreeId))) { worktreeIds.add(worktreeId) } } } return worktreeIds } private getWorkspaceSessionHydrationTargets( includeAllPersistedWorktrees: boolean ): Map { const repos = this.store?.getRepos?.() ?? [] const repoHostIdByRepoId = new Map( repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const) ) const folderHostIdByWorkspaceId = new Map( (this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => { const connectionId = this.resolveFolderWorkspaceConnectionId(workspace) return [ workspace.id, connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID ] as const }) ) const hostIds = new Set(['local']) for (const repo of repos) { hostIds.add(getRepoExecutionHostId(repo)) } for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { hostIds.add(hostId) } const targets = new Map() for (const hostId of hostIds) { const session = this.store?.getWorkspaceSession?.(hostId) if (!session) { continue } for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { const scope = parseWorkspaceKey(worktreeId) const ownerHostId = scope?.type === 'folder' ? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null) : (repoHostIdByRepoId.get( getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId) ) ?? LOCAL_EXECUTION_HOST_ID) if ( ownerHostId === hostId && (includeAllPersistedWorktrees || this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs)) ) { targets.set(worktreeId, session) } } } return targets } getStatus(): RuntimeStatus { // Why: browser panes need a backend that can create and stream a page. A // desktop renderer provides one via ; a headless serve provides one // via the offscreen backend. Either way the same browser.screencast.v1 path // works, so advertise it when either is present. browser.headless.v1 // additionally tells clients this host owns browser pages with no renderer, // so they must not fall back to a local desktop browser tab. const hasRenderer = Boolean(this.getAvailableAuthoritativeWindow()) const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) const canBrowse = hasRenderer || hasOffscreen const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( (capability) => (capability !== 'browser.screencast.v1' || canBrowse) && // Why: the nested-runtime E2E needs a real legacy transport without maintaining an old binary fixture. (process.env.ORCA_E2E_DISABLE_RUNTIME_SHARED_CONTROL !== '1' || capability !== REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) && (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) ) if (hasOffscreen) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } // Why: certificate proceed is owned by the browser-hosting process for both // desktop webviews and offscreen pages. Advertise whenever either backend // can host a page so remote clients can surface Proceed Anyway (Unsafe). if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, // Why: headless orca serve cannot create/stream BrowserViews, so clients // must not treat browser panes as supported just because runtime RPC is up. capabilities, hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } } // Why: scans the transcript-owning host's disk (correct by construction over // RPC — a remote/SSH host scans its own disk). Delegates to the one shared // cache so the desktop panel and the mobile screen never double-scan. listAiVaultSessions(args?: AiVaultListArgs): Promise { return listAiVaultSessions(args) } prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs ): Promise { return ( this.prepareAiVaultSessionResumeFn?.(args) ?? Promise.resolve({ useRealCodexHome: false }) ) } setPtyController(controller: RuntimePtyController | null): void { // Why: CLI terminal writes must go through the main-owned PTY registry // instead of tunneling back through renderer IPC, or live handles could // drift from the process they are supposed to control during reloads. this.ptyController = controller } setNotifier(notifier: RuntimeNotifier | null): void { this.notifier = notifier // Why: run the one-shot fork-upstream backfill once a renderer is attached, // so existing forks self-correct on launch and the result can be broadcast. if (notifier && !this.forkBackfillStarted) { this.forkBackfillStarted = true void this.backfillForkUpstreams() } } onClientEvent( listener: (event: RuntimeClientEvent) => void, options?: { consumesTerminalSideEffects?: boolean } ): () => void { this.clientEventListeners.add(listener) if (options?.consumesTerminalSideEffects === false) { this.terminalSideEffectExcludedClientEventListeners.add(listener) } else { this.terminalSideEffectTitleGateKeysByClientEventListener.set(listener, new Map()) } this.refreshTerminalSideEffectConsumerAvailability() return () => { this.clientEventListeners.delete(listener) this.terminalSideEffectExcludedClientEventListeners.delete(listener) this.terminalSideEffectTitleGateKeysByClientEventListener.delete(listener) this.refreshTerminalSideEffectConsumerAvailability() } } private countTerminalSideEffectConsumingClientEventListeners(): number { return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size } getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] { const events: RuntimeClientEvent[] = [] const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) => a.worktreeId.localeCompare(b.worktreeId) ) for (const state of sleepStates) { const committedPtyIds = new Set(state.ptyIds) if (state.phase === 'stopping') { const pendingPtyIds = Object.keys(state.terminalHandlesByPtyId) .filter((ptyId) => !committedPtyIds.has(ptyId)) .sort() if (pendingPtyIds.length > 0) { events.push({ type: 'worktreeTerminalSleepState', worktreeId: state.worktreeId, generation: state.generation, phase: 'started', ptyIds: pendingPtyIds, terminalHandles: this.getRecordedTerminalSleepHandles( pendingPtyIds, state.terminalHandlesByPtyId ) }) } } if (state.ptyIds.length > 0) { events.push({ type: 'worktreeTerminalSleepState', worktreeId: state.worktreeId, generation: state.generation, phase: 'committed', ptyIds: [...state.ptyIds].sort(), terminalHandles: this.getRecordedTerminalSleepHandles( state.ptyIds, state.terminalHandlesByPtyId ) }) } } return events } getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract< RuntimeClientEvent, { type: 'nativeChatLaunchDraftResolved' } >[] { return [...this.nativeChatLaunchDraftResolutionByTabId.values()] .sort((a, b) => a.tabId.localeCompare(b.tabId)) .map(({ tabId, text, createdAt }) => ({ type: 'nativeChatLaunchDraftResolved', tabId, text, createdAt })) } private emitClientEvent(event: RuntimeClientEvent): void { // Why: filter inside live-Set delivery so a listener removed mid-fan-out // receives nothing and each paired client gets one semantic title frame. // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. notifyRuntimeListeners( this.clientEventListeners, (listener) => { if (event.type === 'terminalSideEffects') { const filtered = this.filterTerminalSideEffectEventForClient(listener, event) if (filtered) { listener(filtered) } } else { listener(event) } }, 'client-event' ) } private filterTerminalSideEffectEventForClient( listener: (event: RuntimeClientEvent) => void, event: Extract ): Extract | null { const titleGateKeys = this.terminalSideEffectTitleGateKeysByClientEventListener.get(listener) if (!titleGateKeys) { return null } const facts = event.batch.facts.filter((fact) => { if (fact.kind !== 'title') { return true } const gateKey = this.makeDecorativeTitleGateKey(fact.rawTitle, fact.normalizedTitle) if (titleGateKeys.get(event.batch.ptyId) === gateKey) { return false } titleGateKeys.set(event.batch.ptyId, gateKey) return true }) if (facts.length === 0) { return null } return facts.length === event.batch.facts.length ? event : { ...event, batch: { ...event.batch, facts } } } notifyNativeChatLaunchDraftResolved( handle: string, resolution: { text: string; createdAt: number } ): void { const owner = this.resolveNativeChatLaunchDraftOwner(handle) if (!owner) { return } const tombstone = { ...owner, ...resolution } this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId) this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone) while ( this.nativeChatLaunchDraftResolutionByTabId.size > MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES ) { const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value if (typeof oldestTabId !== 'string') { break } this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId) } this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone) this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution) this.emitClientEvent({ type: 'nativeChatLaunchDraftResolved', tabId: owner.tabId, ...resolution }) } private resolveNativeChatLaunchDraftOwner( handle: string ): { tabId: string; worktreeId: string } | null { const record = this.handles.get(handle) if (!record) { return null } if (!record.tabId.startsWith('pty:')) { return { tabId: record.tabId, worktreeId: record.worktreeId } } const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null const tabId = pty?.tabId && !pty.tabId.startsWith('pty:') ? pty.tabId : parsePaneKey(pty?.paneKey ?? '')?.tabId if (!pty || !tabId || tabId.startsWith('pty:')) { return null } return { tabId, worktreeId: pty.worktreeId } } private retireResolvedNativeChatLaunchDraftFromMobileSnapshot( resolution: NativeChatLaunchDraftResolutionTombstone ): void { for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) { continue } const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot) if (next === snapshot) { return } this.mobileSessionTabsByWorktree.set(worktreeId, { ...next, snapshotVersion: snapshot.snapshotVersion + 1 }) this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) return } } private applyNativeChatLaunchDraftResolutionFence( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot { let changed = false const tabs = snapshot.tabs.map((tab) => { if (tab.type !== 'terminal') { return tab } const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId) if ( !resolution || !runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) || tab.launchDraft !== resolution.text || tab.launchDraftCreatedAt !== resolution.createdAt ) { return tab } changed = true const next = { ...tab } delete next.launchDraft delete next.launchDraftCreatedAt return next }) return changed ? { ...snapshot, tabs } : snapshot } private reconcileNativeChatLaunchDraftResolutionTombstones( snapshot: RuntimeMobileSessionTabsSnapshot ): void { for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) { if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) { continue } const surfaces = snapshot.tabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' && tab.parentTabId === tabId ) if ( surfaces.length === 0 || !surfaces.some( (tab) => tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt ) ) { this.nativeChatLaunchDraftResolutionByTabId.delete(tabId) } } } private notifyWorktreesChanged(repoId: string): void { this.notifier?.worktreesChanged(repoId) this.emitClientEvent({ type: 'worktreesChanged', repoId }) } /** Detail-level worktree lifecycle tap (plugin event bus). The coarse * worktreesChanged client event carries only repoId, which is not enough * for subscribers that need the affected worktree's identity. * Removal payloads carry no branch: the removal target resolves before * the git worktree is torn down and only pins id + path. */ onWorktreeLifecycle(listener: (event: RuntimeWorktreeLifecycleEvent) => void): () => void { this.worktreeLifecycleListeners.add(listener) return () => { this.worktreeLifecycleListeners.delete(listener) } } private emitWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { for (const listener of this.worktreeLifecycleListeners) { try { listener(event) } catch (err) { console.error('[runtime] worktree lifecycle listener threw', err) } } } private notifyReposChanged(): void { this.notifier?.reposChanged() this.emitClientEvent({ type: 'reposChanged' }) } // Why: SSH state changes originate in main's ssh handlers, not in runtime // methods, so they need a public entry point onto the client-event stream. notifySshStateChanged(targetId: string, state: SshConnectionState): void { this.bumpSshRelayRecoveryGeneration(targetId) this.invalidateSshWorktreeScanCache(targetId) if (state.status !== 'connected') { this.cancelLegacyWorkerTerminalRecoveryRetry(`ssh:${targetId}`) } this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) } notifySshRelayReady(targetId: string): void { const generation = this.bumpSshRelayRecoveryGeneration(targetId) const publish = async (): Promise => { try { await this.publishRecoveredSshMobileSessionTabs(targetId, generation) } catch (error) { if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) === generation) { console.warn('[runtime] failed to publish recovered SSH session tabs', { targetId, error }) } } } const initialPublication = publish() void initialPublication void this.refreshRestoredOrchestrationAuthority(targetId) .then(() => this.reconcileLegacyWorkerTerminals({ connectionId: targetId, materializeRenderer: this.notifier !== null }) ) .then(async () => { await initialPublication await publish() }) .catch((error) => { if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { return } console.warn('[orchestration] legacy worker reconcile failed on relay ready', { targetId, error }) }) } private bumpSshRelayRecoveryGeneration(targetId: string): number { const generation = (this.sshRelayRecoveryGenerationByTargetId.get(targetId) ?? 0) + 1 this.sshRelayRecoveryGenerationByTargetId.set(targetId, generation) return generation } private async publishRecoveredSshMobileSessionTabs( targetId: string, generation: number ): Promise { const repoIds = new Set( (this.store?.getRepos() ?? []) .filter((repo) => repo.connectionId === targetId) .map((repo) => repo.id) ) if (repoIds.size === 0) { return } const worktreeIds = new Set() for (const worktreeId of [ ...this.getKnownWorkspaceSessionWorktreeIds(), ...this.mobileSessionTabsByWorktree.keys() ]) { const parsed = splitWorktreeId(worktreeId) if (parsed && repoIds.has(parsed.repoId)) { worktreeIds.add(worktreeId) } } if (worktreeIds.size === 0) { return } // Why: relay readiness follows PTY reattach; rebuild the HUB-owned panes before paired clients consume the connected event. for (const worktreeId of worktreeIds) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) } await this.refreshMobileSessionPtyRecords() if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { return } for (const worktreeId of worktreeIds) { this.notifyMobileSessionTabsChangedNow(worktreeId) } } invalidateSshWorktreeScanCache(targetId: string): void { this.invalidateSshWorktreeScanCacheInternal(targetId) } // Why: renderer-initiated meta updates intentionally skip the renderer // notifier (the renderer already applied them optimistically), but remote // clients hold no optimistic copy and need the invalidation event. notifyWorktreesChangedForRemoteClients(repoId: string): void { this.invalidateResolvedWorktreeCache() this.emitClientEvent({ type: 'worktreesChanged', repoId }) } // Why: host-local repo IPC mutations never enter runtime methods, so paired // clients need an explicit catalog invalidation; the local renderer already // got its own repos:changed and must not be re-notified (#11994). notifyReposChangedForRemoteClients(): void { this.emitClientEvent({ type: 'reposChanged' }) } private notifyActivateWorktree( repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void { this.notifyHostActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) this.notifyClientsActivateWorktree(repoId, worktreeId, setup, startup, defaultTabs) } private notifyHostActivateWorktree( repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void { this.notifier?.activateWorktree(repoId, worktreeId, setup, startup, defaultTabs) } private notifyClientsActivateWorktree( repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup'], startup?: WorktreeStartupLaunch, defaultTabs?: CreateWorktreeResult['defaultTabs'] ): void { this.emitClientEvent( toRuntimeActivateWorktreeEvent(repoId, worktreeId, setup, startup, defaultTabs) ) } setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void { this.agentBrowserBridge = bridge } getAgentBrowserBridge(): AgentBrowserBridge | null { return this.agentBrowserBridge } setOffscreenBrowserBackend(backend: BrowserBackend | null): void { this.offscreenBrowserBackend = backend } getOffscreenBrowserBackend(): BrowserBackend | null { return this.offscreenBrowserBackend } setEmulatorBridge(bridge: EmulatorBridge | null): void { this.emulatorBridge = bridge } getEmulatorBridge(): EmulatorBridge | null { return this.emulatorBridge } attachWindow(windowId: number): void { if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { // Why: promotion is a renderer reload of the same graph owner, not a new // runtime; stale handles must transition before the real window publishes. this.persistWindowlessPtyBindingsForDesktopAttach() this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) this.authoritativeWindowId = windowId return } if (this.authoritativeWindowId === null) { // Why: a promoted serve can close and later reopen its window while new // background PTYs keep arriving; every windowless gap needs this handoff. this.persistWindowlessPtyBindingsForDesktopAttach() this.authoritativeWindowId = windowId } } private persistWindowlessPtyBindingsForDesktopAttach(): void { if (!this.store?.getWorkspaceSession || !this.store.setWorkspaceSession) { return } const partitions = new Map< ExecutionHostId, { session: WorkspaceSessionState; ptys: RuntimePtyWorktreeRecord[] } >() for (const pty of this.ptysById.values()) { if (!pty.connected || !pty.tabId) { continue } const hostId = this.getWorkspaceSessionHostIdForWorktree(pty.worktreeId) const session = this.store.getWorkspaceSession(hostId) const tab = session.tabsByWorktree[pty.worktreeId]?.find( (candidate) => candidate.id === pty.tabId ) if (!tab) { continue } const layoutPtyIds = Object.values( session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} ) if (tab.ptyId !== pty.ptyId && !layoutPtyIds.includes(pty.ptyId)) { continue } const partition = partitions.get(hostId) ?? { session, ptys: [] } partition.ptys.push(pty) partitions.set(hostId, partition) } for (const [hostId, { session, ptys }] of partitions) { // Why: windowless SSH PTYs must be handed to the desktop through their SSH partition, never the local session. const activeWorktreeIdsOnShutdown = [ ...new Set([ ...(session.activeWorktreeIdsOnShutdown ?? []), ...ptys.map((pty) => pty.worktreeId) ]) ] const activeConnectionIdsAtShutdown = [ ...new Set([ ...(session.activeConnectionIdsAtShutdown ?? []), ...ptys .map((pty) => pty.connectionId) .filter((connectionId): connectionId is string => connectionId !== null) ]) ] const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } for (const pty of ptys) { if (pty.connectionId && pty.tabId) { remoteSessionIdsByTabId[pty.tabId] = pty.ptyId } } this.store.setWorkspaceSession( { ...session, activeWorktreeIdsOnShutdown, ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) }, hostId ) } } syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId } if (windowId !== this.authoritativeWindowId) { throw new Error('Runtime graph publisher does not match the authoritative window') } const previousTabs = this.tabs const previousLeaves = this.leaves this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) const lifecycleLeaves = this.reconcileMobileSessionRetirementFences(graph.leaves) const mobileSessionResyncWorktrees = new Set() const changedMobileWorktrees = this.syncMobileSessionTabs( graph.mobileSessionTabs, graph.unchangedMobileSessionWorktrees, mobileSessionResyncWorktrees ) const nextLeaves = new Map() const graphSyncedAt = this.nextTitleObservationSequence() // Why: renderer reloads can briefly republish the same leaf with no ptyId; // keep live CLI handles usable while the UI graph rebuilds. const preserveLivePtysDuringReload = this.graphStatus === 'reloading' for (const leaf of lifecycleLeaves) { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existing = this.leaves.get(leafKey) const ptyId = preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId ? existing.ptyId : leaf.ptyId const ptyGeneration = existing && existing.ptyId !== ptyId ? existing.ptyGeneration + 1 : (existing?.ptyGeneration ?? 0) const existingPty = ptyId ? this.ptysById.get(ptyId) : undefined const tailSource = existing?.ptyId === ptyId ? existing : existingPty nextLeaves.set(leafKey, { ...leaf, ptyId, ptyGeneration, connected: ptyId !== null, writable: this.graphStatus === 'ready' && ptyId !== null, lastOutputAt: tailSource?.lastOutputAt ?? null, lastExitCode: tailSource?.lastExitCode ?? null, tailBuffer: tailSource?.tailBuffer ?? [], tailTranscriptBuffer: tailSource?.tailTranscriptBuffer ?? [], tailTranscriptChars: tailSource?.tailTranscriptChars ?? 0, tailPartialLine: tailSource?.tailPartialLine ?? '', tailPendingAnsi: tailSource?.tailPendingAnsi ?? '', tailRedrawCursor: tailSource?.tailRedrawCursor ?? null, tailTruncated: tailSource?.tailTruncated ?? false, tailLinesTotal: tailSource?.tailLinesTotal ?? 0, preview: tailSource?.preview ?? '', waitBlockedAt: tailSource?.waitBlockedAt ?? null, lastAgentStatus: tailSource?.lastAgentStatus ?? null, lastOscTitle: tailSource?.lastOscTitle ?? null, lastOscTitleAt: tailSource?.lastOscTitleAt ?? null, paneTitleUpdatedAt: existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle ? existing.paneTitleUpdatedAt : graphSyncedAt }) if (leaf.ptyId) { this.recordPtyWorktree(leaf.ptyId, leaf.worktreeId, { connected: true, lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null, preview: existing?.ptyId === leaf.ptyId ? existing.preview : '', tabId: leaf.tabId, paneKey: this.makeRuntimePaneKey(leaf) }) } if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) { // Why: mobile can subscribe while the pane is waiting for its first PTY. // Keep that handle usable after the recovery mount binds it. const adoptedFirstPty = existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) if (!adoptedFirstPty) { this.invalidateLeafHandle(leafKey) } } } // Why: computed BEFORE preserving stale leaves so preservation can refuse a // leaf whose PTY the incoming graph already rebound to a live leaf. Two // leaves on one PTY resolve to the same handle (handles are ptyId-keyed) and // crash paired clients with a duplicate React key. const nextPtyIds = new Set( [...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId) ) for (const oldLeafKey of this.leaves.keys()) { if (!nextLeaves.has(oldLeafKey)) { const oldLeaf = this.leaves.get(oldLeafKey) if ( preserveLivePtysDuringReload && oldLeaf?.ptyId && this.handleByPtyId.has(oldLeaf.ptyId) && !nextPtyIds.has(oldLeaf.ptyId) ) { // Why: a CLI-created agent keeps using its exported handle even if // the reloaded renderer has not rebound the pane yet. nextLeaves.set(oldLeafKey, oldLeaf) nextPtyIds.add(oldLeaf.ptyId) } else if (oldLeaf?.ptyId && nextPtyIds.has(oldLeaf.ptyId)) { // Why: the incoming graph already rebound this PTY to a live leaf (e.g. // a woken agent re-keyed to a new leaf during renderer reload). Keeping // the old leaf too would put two leaves on ONE PTY, which emit the same // terminal handle and crash paired clients. Drop the stale leaf; if its // handle is the shared ptyId-keyed one it belongs to the live leaf now, // so release only this dead leaf key's alias. A leaf-unique handle has // no next owner — invalidate it so in-flight CLI waiters fail fast // instead of hanging on a dead leaf. const oldHandle = this.handleByLeafKey.get(oldLeafKey) if (oldHandle !== undefined && oldHandle === this.handleByPtyId.get(oldLeaf.ptyId)) { this.handleByLeafKey.delete(oldLeafKey) } else { this.invalidateLeafHandle(oldLeafKey) } } else { this.invalidateLeafHandle(oldLeafKey) } } } for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) { if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) { this.detachedPreAllocatedLeaves.delete(ptyId) continue } nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf) nextPtyIds.add(ptyId) } this.leaves = nextLeaves this.rebuildLeafPtyIndex() // Why: the emitted client payload is a function of the stored snapshot AND // the tab/leaf graph (handles/titles/connected resolve from leaf state), so // a graph-only change — e.g. a restored leaf binding its ptyId while the // snapshot pair is unchanged — must also fan out, or a paired client stays // on pending-handle forever. Schedule the union on the same 50ms trailing // edge as the OSC-title path; the coalescer emit reads the latest state at // fire time so no final version is ever lost. for (const worktreeId of this.collectMobileVisibleGraphChangedWorktrees( previousTabs, previousLeaves )) { if (changedMobileWorktrees.has(worktreeId)) { continue } const stored = this.mobileSessionTabsByWorktree.get(worktreeId) if (!stored) { continue } // Why: web clients drop same-epoch frames whose version isn't strictly // newer, so a graph-only change must mint a fresh stored version (like // the PTY touch path does) or the re-emitted payload — e.g. the // pending-handle → ready flip — is discarded and the client stays stale. // The accepted-renderer tracking is untouched: this is a main-local bump. this.mobileSessionTabsByWorktree.set(worktreeId, { ...stored, snapshotVersion: stored.snapshotVersion + 1 }) changedMobileWorktrees.add(worktreeId) } for (const worktreeId of changedMobileWorktrees) { if (this.mobileSessionTabsByWorktree.has(worktreeId)) { this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } } this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) } // Why: createTerminal waits for the renderer's graph sync to populate the // new leaf so it can return a handle. Drain callbacks after leaves update. for (const cb of [...this.graphSyncCallbacks]) { cb() } const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() const nativeChatLaunchDraftResolutions = this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map( ({ tabId, text, createdAt }) => ({ tabId, text, createdAt }) ) return { ...this.getStatus(), ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}), ...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {}), ...(mobileSessionResyncWorktrees.size > 0 ? { mobileSessionResyncWorktrees: [...mobileSessionResyncWorktrees] } : {}) } } // Why: toMobileSessionTabsResult resolves handles/titles from this.tabs and // this.leaves, so any tab/leaf delta a graph sync installs can flip the // client payload (pending-handle → ready, tab title) with zero change to the // stored snapshot. Compare exactly the projection-relevant fields and report // the affected worktrees; false positives only cost a coalesced no-op emit. private collectMobileVisibleGraphChangedWorktrees( previousTabs: Map, previousLeaves: Map ): Set { const changed = new Set() for (const [tabId, tab] of this.tabs) { const prev = previousTabs.get(tabId) if (!prev || prev.title !== tab.title) { changed.add(tab.worktreeId) } } for (const [tabId, tab] of previousTabs) { if (!this.tabs.has(tabId)) { changed.add(tab.worktreeId) } } for (const [leafKey, leaf] of this.leaves) { const prev = previousLeaves.get(leafKey) if ( !prev || prev.ptyId !== leaf.ptyId || prev.connected !== leaf.connected || prev.paneTitle !== leaf.paneTitle ) { changed.add(leaf.worktreeId) } } for (const [leafKey, leaf] of previousLeaves) { if (!this.leaves.has(leafKey)) { changed.add(leaf.worktreeId) } } return changed } async listMobileSessionTabs( worktreeSelector: string, clientNavigationId?: string ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) if (explicitWorktreeId) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId) await this.refreshMobileSessionPtyRecords(explicitWorktreeId) this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId) return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId) } const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id) await this.refreshMobileSessionPtyRecords() this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id) return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId) } async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() await this.refreshMobileSessionPtyRecords() return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => this.clientSessionTabSelections.project( this.toMobileSessionTabsResult(snapshot), clientNavigationId ) ) } private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId?: string, options: { force?: boolean allowAttachedWindow?: boolean onlyRuntimeOwnedTerminals?: boolean runtimeOwnedTerminalCandidateKnown?: boolean workspaceSession?: WorkspaceSessionState } = {} ): Set { // Why: report which worktrees were reconciled in place so callers don't // reconcile them a second time (see notifyMobileSessionTabsChanged). const reconciledWorktreeIds = new Set() if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { return reconciledWorktreeIds } const session = options.workspaceSession ?? (worktreeId ? this.getWorkspaceSessionForWorktree(worktreeId) : this.store?.getWorkspaceSession?.()) if (!session) { return reconciledWorktreeIds } // Why: with no runtime-owned candidate in the session and no offscreen // browser backend, this hydrate provably builds zero tabs for // every worktree — skip the per-worktree rebuild entirely (hot on every // graph sync). Scoped to onlyRuntimeOwnedTerminals so full hydrates are // untouched. if ( options.onlyRuntimeOwnedTerminals === true && !this.offscreenBrowserBackend && options.runtimeOwnedTerminalCandidateKnown !== true && !(worktreeId ? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( session, worktreeId, session.tabsByWorktree[worktreeId] ?? [] ) : this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)) ) { return reconciledWorktreeIds } const entries = worktreeId !== undefined ? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const) : Object.entries(session.tabsByWorktree ?? {}) // Why: workspaceSession keys are `${repoId}::${path}` and are not pruned when // a repo disappears from this client's view (e.g. removed on another client, // or a stale browser-persisted session). Hydrating such a key would surface a // phantom "unknown"/duplicate workspace with no live repo behind it. Only // hydrate sessions whose repo still exists; leave unparseable keys alone. // Resolved lazily so unparseable keys (floating terminals) never pay for a // repo inventory on the hot poll path, and `null` when the store cannot // report repos — an unavailable list must not read as "every repo is gone". let liveRepoIds: Set | null | undefined for (const [entryWorktreeId, persistedTabs] of entries) { const ownerRepoId = splitWorktreeIdForFilesystem(entryWorktreeId)?.repoId if (ownerRepoId) { if (liveRepoIds === undefined) { const knownRepos = this.store?.getRepos?.() liveRepoIds = knownRepos ? new Set(knownRepos.map((repo) => repo.id)) : null } if (liveRepoIds && !liveRepoIds.has(ownerRepoId)) { continue } } const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) if ( existing && existing.tabs.length > 0 && options.force !== true && options.onlyRuntimeOwnedTerminals !== true ) { // Why: terminals are stable/persisted so we normally skip a rebuild, but // offscreen browser tabs are live and may have been created/closed since. // Reconcile just the browser tabs against the live bridge instead of // leaving a stale snapshot that omits a freshly-opened browser tab. this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) reconciledWorktreeIds.add(entryWorktreeId) continue } const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( entryWorktreeId, persistedTabs, session ).filter( (tab) => options.onlyRuntimeOwnedTerminals !== true || this.hasServeOrSshOwnedBinding(tab) || this.hasRecentExpiredSshLeasePane(entryWorktreeId, tab) ) // Why: offscreen browser panes are live-only (no persisted session entry), // so include them on every hydrate regardless of the onlyRuntimeOwnedTerminals // filter, which is about terminal PTY ownership and never applies to browsers. const browserTabs = this.buildHeadlessMobileSessionBrowserTabs(entryWorktreeId) const tabs: RuntimeMobileSessionSnapshotTab[] = [...terminalTabs, ...browserTabs] if (tabs.length === 0) { continue } const activeTab = this.pickHeadlessActiveTerminalTab(terminalTabs) const tabOrder = [ ...this.collectHeadlessParentTabOrder(terminalTabs), ...browserTabs.map((tab) => tab.id) ] const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) const mergedTabs = options.onlyRuntimeOwnedTerminals === true && existing ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) : tabs const mergedActiveTab = existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? activeTab ?? mergedTabs[0] ?? null const mergedTerminalTabs = mergedTabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) const mergedBrowserOrder = mergedTabs .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') .map((tab) => tab.id) // Why: a persisted multi-group split must be restored on cold rebuild, or // the headless serve coalesces the user's group layout back into one group // (the persisted tabGroups/tabGroupLayouts would otherwise be write-only). const persistedGroups = session.tabGroups?.[entryWorktreeId] const persistedLayout = session.tabGroupLayouts?.[entryWorktreeId] const hasPersistedSplit = options.onlyRuntimeOwnedTerminals !== true && persistedGroups !== undefined && persistedGroups.length > 1 const activeTopLevelId = mergedActiveTab ? mergedActiveTab.type === 'terminal' ? mergedActiveTab.parentTabId : mergedActiveTab.id : null const nextTabGroups: RuntimeMobileSessionTabGroup[] = hasPersistedSplit ? this.appendBrowserTabOrder( this.distributeHeadlessTabsAcrossGroups( persistedGroups.map((group) => ({ id: group.id, activeTabId: group.activeTabId, tabOrder: [...group.tabOrder], ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) })), this.collectHeadlessParentTabOrder(mergedTerminalTabs), activeTopLevelId ), mergedBrowserOrder, undefined, // Why: distribute drops browser ids (terminal-only), so carry each // browser's persisted group forward instead of coalescing left. this.collectBrowserGroupAssignment(persistedGroups, mergedBrowserOrder) ) : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroups ? this.appendBrowserTabOrder( this.mergeMobileSessionTabGroups( entryWorktreeId, existing.tabGroups, mergedTerminalTabs, mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null ), mergedBrowserOrder ) : [ { id: groupId, activeTabId: mergedActiveTab?.id ? (activeTab?.parentTabId ?? mergedActiveTab.id) : (tabOrder[0] ?? null), tabOrder } ] // Why: merging runtime tabs INTO a renderer publication must not reclass // the snapshot as headless-built — the preservation predicate would then // treat the renderer's own tabs as runtime-owned and resurrect tabs the // renderer later closes. Keep the renderer base epoch with a merge suffix // (idempotent) so ownership stays derivable from the epoch. const mergedIntoRendererPublication = options.onlyRuntimeOwnedTerminals === true && existing !== undefined && !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { worktree: existing?.worktree ?? entryWorktreeId, publicationEpoch: mergedIntoRendererPublication ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) : `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, activeGroupId: existing?.activeGroupId ?? groupId, activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, tabGroups: nextTabGroups, // Why: the runtime-owned rebuild runs on every graph sync — carry the // existing split layout forward or each sync drops it and fans out. ...(hasPersistedSplit && persistedLayout ? { tabGroupLayout: persistedLayout } : options.onlyRuntimeOwnedTerminals === true && existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs: mergedTabs } // Why: the runtime-owned hydrate runs on EVERY graph sync; when the rebuilt // projection matches the existing snapshot, keep the existing object and // (epoch, version) untouched so identity-based change detection stays a // pure no-op and unchanged runtime/browser worktrees never fan out. if (existing && this.headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { continue } this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot) } return reconciledWorktreeIds } // Why: content equality for the hydrate's idempotence check — compares every // client-visible field EXCEPT publicationEpoch/snapshotVersion (both are // freshly minted on each rebuild and would defeat the comparison). Tab and // group objects are rebuilt each hydrate, so compare by value, not identity. private headlessMobileSnapshotContentUnchanged( existing: RuntimeMobileSessionTabsSnapshot, next: RuntimeMobileSessionTabsSnapshot ): boolean { if ( existing.worktree !== next.worktree || existing.activeGroupId !== next.activeGroupId || existing.activeTabId !== next.activeTabId || existing.activeTabType !== next.activeTabType ) { return false } // Why: this runs per persisted worktree on EVERY graph sync whenever a // serve PTY exists, so compare structurally instead of stable-stringifying // both sides (which allocated six full serialized trees per worktree). return ( this.mobileSnapshotValueEqual(existing.tabs, next.tabs) && this.mobileSnapshotValueEqual(existing.tabGroups ?? null, next.tabGroups ?? null) && this.mobileSnapshotValueEqual(existing.tabGroupLayout ?? null, next.tabGroupLayout ?? null) ) } // Deep structural equality over plain snapshot JSON (objects/arrays/scalars). // Key order is irrelevant; a mismatch only costs a coalesced no-op emit. private mobileSnapshotValueEqual(a: unknown, b: unknown): boolean { if (a === b) { return true } if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { return false } for (let index = 0; index < a.length; index++) { if (!this.mobileSnapshotValueEqual(a[index], b[index])) { return false } } return true } if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') { const aRecord = a as Record const bRecord = b as Record const aKeys = Object.keys(aRecord) if (aKeys.length !== Object.keys(bRecord).length) { return false } for (const key of aKeys) { if ( !Object.hasOwn(bRecord, key) || !this.mobileSnapshotValueEqual(aRecord[key], bRecord[key]) ) { return false } } return true } return false } // Why: keep an existing snapshot's browser tabs in sync with the live bridge // without rebuilding stable terminal state. Replaces browser entries with the // current live set and rewrites the browser portion of the primary group order. private reconcileHeadlessMobileSessionBrowserTabs( worktreeId: string, existing: RuntimeMobileSessionTabsSnapshot ): void { if (!this.offscreenBrowserBackend) { return } const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) const liveIds = liveBrowserTabs.map((tab) => tab.id) const existingBrowserTabs = existing.tabs.filter( (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' ) const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return } const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') const nextTabs: RuntimeMobileSessionSnapshotTab[] = [...nonBrowserTabs, ...liveBrowserTabs] const liveIdSet = new Set(liveIds) const tabGroups = this.appendBrowserTabOrder( (existing.tabGroups ?? []).map((group) => ({ ...group, // Drop closed browser ids; appendBrowserTabOrder re-adds the live ones. tabOrder: group.tabOrder.filter( (id) => liveIdSet.has(id) || !existingBrowserIds.includes(id) ) })), liveIds ) const activeStillPresent = nextTabs.some((tab) => tab.id === existing.activeTabId) const active = activeStillPresent ? null : (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null) this.mobileSessionTabsByWorktree.set(worktreeId, { ...existing, publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: existing.snapshotVersion + 1, ...(activeStillPresent ? {} : { activeTabId: active?.id ?? null, activeTabType: active?.type ?? null }), tabGroups, tabs: nextTabs }) } // Why: browser session tabs have no parentTabId so the terminal-only group // builder drops them from tabOrder; this re-adds their ids to a group. // Browser tabs are live-only (no persisted session entry), but their GROUP // membership must still survive snapshot rebuilds like terminals'. The // passed-in groups already encode each browser's group (carried from the prior // snapshot / persisted tabGroups), so keep each existing browser id where it // is; only a genuinely-new browser id goes to its create-target group (when // that group exists) and otherwise to the first group. Previously every // browser was force-pushed into group[0], so opening a browser in the right // split group always snapped it back to the left on the next rebuild. private appendBrowserTabOrder( groups: readonly RuntimeMobileSessionTabGroup[], browserTabIds: readonly string[], newTabAssignment?: { tabId: string; groupId: string }, // browserPageId -> groupId from the prior/persisted groups. The terminal // distributor rebuilds tabOrder from terminal ids only and drops browser // ids, so this carries each browser's group across rebuilds. priorGroupByBrowserId?: ReadonlyMap ): RuntimeMobileSessionTabGroup[] { if (browserTabIds.length === 0) { return [...groups] } const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) if (next.length === 0) { return next } const groupById = new Map(next.map((group) => [group.id, group])) const ownerGroupByTabId = new Map() for (const group of next) { for (const id of group.tabOrder) { ownerGroupByTabId.set(id, group) } } for (const id of browserTabIds) { if (ownerGroupByTabId.has(id)) { continue } const priorGroupId = priorGroupByBrowserId?.get(id) const targetGroup = (newTabAssignment?.tabId === id ? groupById.get(newTabAssignment.groupId) : undefined) ?? (priorGroupId ? groupById.get(priorGroupId) : undefined) ?? next[0]! targetGroup.tabOrder.push(id) } return next } // browserPageId -> groupId from a set of groups (the persisted/prior layout), // so a browser stays in its group across rebuilds that drop browser ids. private collectBrowserGroupAssignment( groups: readonly RuntimeMobileSessionTabGroup[] | undefined, browserTabIds: readonly string[] ): Map { const browserIdSet = new Set(browserTabIds) const assignment = new Map() for (const group of groups ?? []) { for (const id of group.tabOrder) { if (browserIdSet.has(id)) { assignment.set(id, group.id) } } } return assignment } private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && ptyId.startsWith('serve-') } private isSshOwnedPtyId(ptyId: string | null | undefined): boolean { return typeof ptyId === 'string' && parseAppSshPtyId(ptyId) !== null } private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean { return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) => this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs) ) } private workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate( session: WorkspaceSessionState, worktreeId: string, tabs: WorkspaceSessionState['tabsByWorktree'][string] ): boolean { return tabs.some((tab) => { if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { return true } const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId return ( (leafPtyIds && Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) || // Why: expiry keeps pane coordinates so paired viewers can request a fresh shell. this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null ) }) } private getRecentExpiredSshLease( worktreeId: string, tabId: string, leafId: string | undefined, ptyId?: string ): ReturnType>[number] | null { const now = Date.now() return ( this.store ?.getSshRemotePtyLeases?.() .find( (lease) => lease.state === 'expired' && lease.worktreeId === worktreeId && lease.tabId === tabId && (ptyId === undefined || lease.ptyId === ptyId) && (leafId === undefined || lease.leafId === undefined || lease.leafId === leafId) && lease.updatedAt <= now && now - lease.updatedAt <= SSH_PANE_RECOVERY_GRACE_MS ) ?? null ) } private hasRecentExpiredSshLeasePane( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): boolean { return this.getRecentExpiredSshLease(worktreeId, tab.parentTabId, tab.leafId) !== null } // Why: serve-* (local serve) and ssh:@@ (SSH relay) ids are minted // ONLY for runtime-owned terminals and are preserved/re-hydrated, so tear them // down even if the renderer adopted a view (else they resurrect). The daemon // session form @@ is deliberately NOT here: the daemon // mints it for ordinary renderer-owned local terminals too, so id shape can't // classify ownership for that form — renderer-graph membership does (below). private isServeOrSshOwnedPtyId(ptyId: string | null | undefined): boolean { return this.isServeOwnedPtyId(ptyId) || this.isSshOwnedPtyId(ptyId) } private hasServeOrSshOwnedBinding(tab: RuntimeMobileSessionTerminalTab): boolean { if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { return true } return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId) ) } // Why: a snapshot tab can keep a serve/SSH-owned ptyId after the runtime // terminal died and was de-persisted, so id shape alone must not preserve it // against a renderer publication. Require the binding to be backed by a live // PTY or by the persisted workspace session (a dormant persisted serve/SSH // binding is still re-hydratable, so it stays preserved). private hasLiveOrPersistedServeOrSshOwnedPtyBinding( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): boolean { const boundPtyIds = [ tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}) ].filter((ptyId): ptyId is string => this.isServeOrSshOwnedPtyId(ptyId)) const boundSshPtyIds = boundPtyIds.filter((ptyId) => this.isSshOwnedPtyId(ptyId)) if (boundPtyIds.length === 0) { return this.hasRecentExpiredSshLeasePane(worktreeId, tab) } // Why: exited PTY records are archived in ptysById, so require a connected // record — a dead serve shell whose persisted binding is also gone must // stop being preserved. if (boundPtyIds.some((ptyId) => this.ptysById.get(ptyId)?.connected === true)) { return true } const now = Date.now() if ( boundPtyIds.some((ptyId) => { const pty = this.ptysById.get(ptyId) return ( pty?.connectionId != null && pty.lastExitCode != null && pty.lastExitCode < 0 && pty.disconnectedAt != null && now - pty.disconnectedAt <= SSH_PANE_RECOVERY_GRACE_MS ) }) ) { // Why: an abnormal SSH transport exit can beat paired-viewer recovery; retain its pane briefly so the HUB remains addressable. return true } if ( now - this.startedAt <= SSH_PANE_RECOVERY_GRACE_MS && boundSshPtyIds.some((ptyId) => { const pty = this.ptysById.get(ptyId) return !pty || (!pty.connected && pty.lastExitCode === null) }) ) { // Why: after a HUB restart, failed SSH reattach can remove persistence before the fresh runtime records an exit; keep the pane reachable for ensure. return true } const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session) { return false } const persistedTab = (session.tabsByWorktree?.[worktreeId] ?? []).find( (candidate) => candidate.id === tab.parentTabId ) if (!persistedTab) { return false } const persistedPtyIds = new Set( [ persistedTab.ptyId, ...Object.values(session.terminalLayoutsByTabId?.[persistedTab.id]?.ptyIdsByLeafId ?? {}) ].filter((ptyId): ptyId is string => typeof ptyId === 'string') ) return boundPtyIds.some((ptyId) => persistedPtyIds.has(ptyId)) } private hasLiveRuntimeSessionOwnedPtyBinding( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): boolean { const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) return pty?.connected === true && pty.runtimeSessionOwned } private clearRuntimeSessionOwnershipForMobileTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, parentTabId: string ): void { for (const tab of snapshot.tabs) { if (tab.type !== 'terminal' || tab.parentTabId !== parentTabId) { continue } const ptyIds = [tab.ptyId, ...Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})].filter( (ptyId): ptyId is string => typeof ptyId === 'string' ) for (const ptyId of ptyIds) { const pty = this.ptysById.get(ptyId) if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) { pty.runtimeSessionOwned = false this.setPairedRendererSessionOwnership(pty.ptyId, false) } } } } // Why: a tab needs authoritative runtime teardown (kill + de-persist + prune) // only when the renderer can't durably tear it down: either it's serve/SSH // (preserved + re-hydrated, would resurrect) or the renderer graph never // published it (a leaked/unadopted shell — incl. daemon-session `@@` tabs the // host materialized but the renderer never showed). A tab the renderer graph // DOES list — including an ordinary daemon-backed local terminal or a pending // tab whose PTY hasn't bound — is renderer-owned: delegate, do not de-persist. private isRuntimeOwnedHeadlessMobileTab( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): boolean { if (this.hasServeOrSshOwnedBinding(tab)) { return true } const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) if (pty && this.isServeOrSshOwnedPtyId(pty.ptyId)) { return true } return !this.tabs.has(tab.parentTabId) } private mergeMobileSessionSnapshotTabs( baseTabs: readonly RuntimeMobileSessionSnapshotTab[], extraTabs: readonly RuntimeMobileSessionSnapshotTab[] ): RuntimeMobileSessionSnapshotTab[] { const seenIds = new Set() const merged: RuntimeMobileSessionSnapshotTab[] = [] const add = (tab: RuntimeMobileSessionSnapshotTab): void => { const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab) if (ids.some((id) => seenIds.has(id))) { return } for (const id of ids) { seenIds.add(id) } merged.push(tab) } for (const tab of baseTabs) { add(tab) } for (const tab of extraTabs) { add(tab) } return merged } private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] { if (tab.type === 'terminal') { // Why: split terminal leaves share one parent tab; merge dedup must stay // leaf-scoped or preserved siblings collapse into a single surface. const keys = [tab.id, `${tab.parentTabId}::${tab.leafId}`] if (typeof tab.ptyId === 'string' && tab.ptyId.length > 0) { // Why: renderer and headless sources can derive different leafIds for the same // terminal; real PTYs collapse those duplicates without merging pending splits. keys.push(`${tab.parentTabId}::pty:${tab.ptyId}`) } return keys } if (tab.type === 'browser') { return [tab.id, tab.browserWorkspaceId] } return [tab.id] } private mergeMobileSessionTabGroups( worktreeId: string, groups: readonly RuntimeMobileSessionTabGroup[], terminalTabs: readonly RuntimeMobileSessionTerminalTab[], activeTab: RuntimeMobileSessionTerminalTab | null ): RuntimeMobileSessionTabGroup[] { const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs) if (parentTabOrder.length === 0) { return [...groups] } const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) const nextGroups = groups.length > 0 ? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) : [ { id: targetGroupId, activeTabId: null, tabOrder: [] } ] // Why: keep each tab in the group that already owns it (a multi-group split // must survive the merge), drop tabs no longer present, and route only // genuinely-new tabs into the active group — never funnel everything into // group[0], which duplicated/coalesced tabs that lived in other groups. const ownerGroupId = new Map() for (const group of nextGroups) { for (const tabId of group.tabOrder) { ownerGroupId.set(tabId, group.id) } } const liveTabIds = new Set(parentTabOrder) const activeParentId = activeTab?.parentTabId ?? null const activeGroupId = (activeParentId ? ownerGroupId.get(activeParentId) : undefined) ?? nextGroups[0]!.id const retainedOrder = new Map(nextGroups.map((group) => [group.id, []])) for (const tabId of parentTabOrder) { const groupId = ownerGroupId.get(tabId) ?? activeGroupId retainedOrder.get(groupId)?.push(tabId) } return nextGroups .map((group) => { const tabOrder = retainedOrder.get(group.id) ?? [] const keptActive = group.activeTabId && tabOrder.includes(group.activeTabId) && liveTabIds.has(group.activeTabId) ? group.activeTabId : null return { ...group, tabOrder, activeTabId: activeParentId && tabOrder.includes(activeParentId) ? activeParentId : (keptActive ?? tabOrder[0] ?? null) } }) .filter((group) => group.tabOrder.length > 0) } /** * Publishes a PTY-backed terminal tab snapshot to the synced mobile session, * normalizing Pi-compatible titles based on launch or foreground ownership. */ private publishPtyBackedMobileSessionTerminal( worktreeId: string, pty: RuntimePtyWorktreeRecord, args: { tabId: string leafId: string title: string | null activate: boolean selectIfNoActiveTab?: boolean startupCwd?: string viewMode?: 'terminal' | 'chat' split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } notify?: boolean } ): void { if ( !this.isMobileSessionSurfaceMembershipAllowed(worktreeId, args.tabId, args.leafId, pty.ptyId) ) { return } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', ownerAgent ) const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === args.tabId && candidate.leafId === args.leafId ) // Why: a split inserts into the parent tab's layout, which lives on the // sibling surface, not this new leaf's (empty) existing surface. const baseLayout = args.split ? (existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === args.tabId && candidate.leafId === args.split!.splitFromLeafId )?.parentLayout ?? existingTab?.parentLayout) : existingTab?.parentLayout const parentLayout = this.buildMaterializedHeadlessParentLayout( args.leafId, pty.ptyId, baseLayout, args.split ) // Why: a main-side PTY rescue or split publication must not erase the // host's explicit tab mode before the renderer graph catches up. const viewMode = args.viewMode ?? existingTab?.viewMode ?? existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === args.tabId && candidate.viewMode !== undefined )?.viewMode const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${args.tabId}::${args.leafId}`, parentTabId: args.tabId, leafId: args.leafId, ptyId: pty.ptyId, title, ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), ...(viewMode ? { viewMode } : {}), parentLayout, isActive: args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) } const existingTabs = (existing?.tabs ?? []).filter( (candidate) => !( candidate.type === 'terminal' && candidate.parentTabId === args.tabId && candidate.leafId === args.leafId ) ) const tabs = this.mergeMobileSessionSnapshotTabs( existingTabs.map((candidate) => ({ ...candidate, // Why: the client picks one sibling's parentLayout to render the whole // tab; a split must update every sibling surface to the new tree, or a // stale single-leaf sibling makes the client fall back to a default // direction ("Split Right" renders as down). ...(args.split && candidate.type === 'terminal' && candidate.parentTabId === args.tabId ? { parentLayout } : {}), isActive: tab.isActive ? false : candidate.isActive })), [tab] ) const activeTab = (tab.isActive ? tab : tabs.find((candidate) => candidate.id === existing?.activeTabId)) ?? tabs.find((candidate) => candidate.isActive) ?? (args.selectIfNoActiveTab !== false ? tabs[0] : null) ?? null const terminalTabs = tabs.filter( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' ) const next: RuntimeMobileSessionTabsSnapshot = { worktree: worktreeId, publicationEpoch: existing?.publicationEpoch ?? `headless:pty-backed:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, activeGroupId: existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId), activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, tabGroups: this.mergeMobileSessionTabGroups( worktreeId, existing?.tabGroups ?? [], terminalTabs, activeTab?.type === 'terminal' ? activeTab : null ), ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } this.mobileSessionTabsByWorktree.set(worktreeId, next) if (args.notify !== false) { this.notifyMobileSessionTabsChanged(worktreeId) } } private touchMobileSessionSnapshotsForPty( ptyId: string, options: { immediate?: boolean } = {} ): void { for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const hasPtyBackedTab = snapshot.tabs.some( (tab) => tab.type === 'terminal' && (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) ) if (!hasPtyBackedTab) { continue } this.touchMobileSessionTabsForWorktree(worktreeId, options) } } /** Bump the snapshot version and emit, coalesced unless `immediate`. * Why the bump: clients gate mirrored snapshots on a strictly increasing * `snapshotVersion`, so a re-emit at the same version is silently dropped. */ touchMobileSessionTabsForWorktree( worktreeId: string, options: { immediate?: boolean } = {} ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { return } this.mobileSessionTabsByWorktree.set(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) if (options.immediate) { // Why: readiness/lifecycle changes are structural and must not wait // behind the title/status coalescing window. this.notifyMobileSessionTabsChanged(worktreeId) return } // Why: title/status flips several times a second under spinner-in-title // agents. Coalesce the emit instead of fanning out every version. this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) } /** Republish the workspace snapshot after a pane's hook status changed. * Hook rows feed the headless `agentStatus` projection, which nothing else touches. */ touchMobileSessionTabsForPane(paneKey: string, worktreeId?: string | null): void { const resolved = worktreeId ?? this.getTerminalWorktreeIdForPaneKey(paneKey) if (!resolved) { return } this.touchMobileSessionTabsForWorktree(resolved) } private mobileSessionSnapshotHasSurface( worktreeId: string, parentTabId: string, leafId: string ): boolean { return Boolean( this.mobileSessionTabsByWorktree .get(worktreeId) ?.tabs.some( (tab) => tab.type === 'terminal' && tab.parentTabId === parentTabId && tab.leafId === leafId ) ) } private isMobileSessionSurfaceMembershipAllowed( worktreeId: string, parentTabId: string, leafId: string, candidatePtyId: string | null | undefined ): boolean { const session = this.store?.getWorkspaceSession?.() const repoId = getRepoIdFromWorktreeId(worktreeId) if ( !hasHostAuthoritativeTerminalMembership(session, worktreeId) && (session !== undefined || !this.terminalTopologyRevisionByRepoId.has(repoId)) ) { return true } if (this.mobileSessionSnapshotHasSurface(worktreeId, parentTabId, leafId)) { return true } if (!candidatePtyId) { return false } const pty = this.ptysById.get(candidatePtyId) const pane = parsePaneKey(pty?.paneKey ?? '') return Boolean( pty?.connected && pty.worktreeId === worktreeId && pty.tabId === parentTabId && pane?.leafId === leafId ) } private reconcileMobileSessionRetirementFences( leaves: readonly RuntimeSyncedLeaf[] ): RuntimeSyncedLeaf[] { return leaves.filter((leaf) => this.isMobileSessionSurfaceMembershipAllowed( leaf.worktreeId, leaf.tabId, leaf.leafId, leaf.ptyId ) ) } private applyMobileSessionRetirementFences( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot { let next = snapshot for (const tab of snapshot.tabs) { if ( tab.type !== 'terminal' || this.isMobileSessionSurfaceMembershipAllowed( snapshot.worktree, tab.parentTabId, tab.leafId, tab.ptyId ) ) { continue } const retired = retireTerminalSurfacesFromSnapshot({ snapshot: next, ptyId: tab.ptyId ?? '', exactSurfaces: [{ parentTabId: tab.parentTabId, leafId: tab.leafId }], exactOnly: true }) if (retired) { next = retired.snapshot } } return next } /** * Retires each surface in the session partition of the host that owns its worktree. * Why: an SSH pane's durable surface lives in that connection's partition; retiring it * against the local partition strands the real ghost and bumps a foreign host's epoch. * Returns null when nothing may be published because persistence is unavailable or failed. */ private persistTerminalSurfaceRetirements( retiredSurfaces: readonly RetiredTerminalSurface[] ): { accepted: RetiredTerminalSurface[]; unpersisted: RetiredTerminalSurface[] } | null { const surfacesByHostId = new Map() for (const surface of retiredSurfaces) { const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(surface.worktreeId) ?? LOCAL_EXECUTION_HOST_ID const bucket = surfacesByHostId.get(hostId) if (bucket) { bucket.push(surface) } else { surfacesByHostId.set(hostId, [surface]) } } const accepted: RetiredTerminalSurface[] = [] const unpersisted: RetiredTerminalSurface[] = [] const pendingWrites: { hostId: ExecutionHostId; session: WorkspaceSessionState }[] = [] for (const [hostId, surfaces] of surfacesByHostId) { const session = this.store?.getWorkspaceSession?.(hostId) if (!session) { unpersisted.push(...surfaces) continue } // Why: publishing absence before its host membership fence is durable lets a crash or // stale renderer write resurrect the retired surface. if (!this.store?.setWorkspaceSession || !this.store.flushOrThrow) { return null } let nextSession = session const acceptedForHost: RetiredTerminalSurface[] = [] for (const surface of surfaces) { const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) if (candidate !== nextSession) { acceptedForHost.push(surface) nextSession = candidate } } if (acceptedForHost.length === 0) { continue } accepted.push(...acceptedForHost) pendingWrites.push({ hostId, session: nextSession }) } if (pendingWrites.length > 0) { try { for (const write of pendingWrites) { this.store?.setWorkspaceSession?.(write.session, write.hostId) } this.store?.flushOrThrow?.() } catch (error) { console.error('[runtime] failed to persist terminal retirement:', error) return null } } return { accepted, unpersisted } } private retireMobileSessionSurfacesForPty( ptyId: string, incarnationId: string, exactSurfaces: readonly Pick[] ): void { const retiredSurfaceByKey = new Map() for (const surface of exactSurfaces) { retiredSurfaceByKey.set(`${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, { ...surface, ptyId, incarnationId }) } for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const retired = retireTerminalSurfacesFromSnapshot({ snapshot, ptyId, exactSurfaces: exactSurfaces.filter((surface) => surface.worktreeId === worktreeId) }) if (!retired) { continue } for (const surface of retired.retired) { retiredSurfaceByKey.set( `${surface.worktreeId}\0${surface.parentTabId}\0${surface.leafId}`, { ...surface, incarnationId } ) } } const retiredSurfaces = [...retiredSurfaceByKey.values()] if (retiredSurfaces.length === 0) { return } const persisted = this.persistTerminalSurfaceRetirements(retiredSurfaces) if (!persisted) { return } for (const surface of persisted.unpersisted) { const repoId = getRepoIdFromWorktreeId(surface.worktreeId) this.terminalTopologyRevisionByRepoId.set( repoId, (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 ) } // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. const publishableRetiredSurfaces = [...persisted.accepted, ...persisted.unpersisted] if (publishableRetiredSurfaces.length === 0) { return } for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const retired = retireTerminalSurfacesFromSnapshot({ snapshot, ptyId, exactSurfaces: publishableRetiredSurfaces.filter( (surface) => surface.worktreeId === worktreeId ), // Why: discovery is broad by PTY id, but publication may remove only surfaces whose durable retirement was accepted. exactOnly: true }) if (retired) { this.mobileSessionTabsByWorktree.set(worktreeId, retired.snapshot) this.notifyMobileSessionTabsChanged(worktreeId) } } } private buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[], session: WorkspaceSessionState ): RuntimeMobileSessionTerminalTab[] { return [...persistedTabs] .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) .flatMap((tab, index) => { const layout = session.terminalLayoutsByTabId?.[tab.id] const leafIds = this.collectPersistedTerminalLeafIds(layout) if (leafIds.length === 0) { leafIds.push(this.deriveHeadlessLegacyTerminalLeafId(tab.id)) } return leafIds.flatMap((leafId) => { const ptyId = layout?.ptyIdsByLeafId?.[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null) const title = tab.customTitle?.trim() || tab.generatedTitle?.trim() || tab.title?.trim() || tab.defaultTitle?.trim() || `Terminal ${index + 1}` return [ { type: 'terminal' as const, id: `${tab.id}::${leafId}`, parentTabId: tab.id, leafId, title, ...(ptyId ? { ptyId } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}), ...(layout ? { parentLayout: this.cloneTerminalLayoutSnapshot(layout) } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), isActive: this.isPersistedTerminalLeafActive( session, worktreeId, tab.id, leafId, layout ) } ] }) }) } // Why: headless serve backs browser panes with offscreen WebContents that live // only in the BrowserManager, never in a renderer graph. Without surfacing them // as session tabs, a session.tabs snapshot (e.g. on terminal open) prunes the // paired browser tab and closing it fails with tab_not_found. Synthesize browser // session tabs from the live bridge so they are first-class alongside terminals. private buildHeadlessMobileSessionBrowserTabs( worktreeId: string ): RuntimeMobileSessionBrowserTab[] { if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { return [] } return this.agentBrowserBridge.tabList(worktreeId).tabs.map((tab) => { const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId) return { type: 'browser' as const, // Why: an offscreen page has no separate workspace identity, so the page id // is its own workspace id (matches the server's browserWorkspaceId fallback). id: tab.browserPageId, title: tab.title || tab.url || 'Browser', browserWorkspaceId: tab.browserPageId, browserPageId: tab.browserPageId, url: tab.url || 'about:blank', loading: false, canGoBack: false, canGoForward: false, loadError: tab.loadError ?? undefined, certificateFailure: tab.certificateFailure ?? undefined, ...(persistedProps ? { color: persistedProps.color } : {}), ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), isActive: tab.active === true } }) } // Why: change detection for headless browser tabs. Compares the fields that // actually vary (a JSON.stringify equality was order-sensitive and silently // dropped `undefined` keys, so it only worked while both sides shared one // construction path). private headlessBrowserTabsUnchanged( live: RuntimeMobileSessionBrowserTab[], existing: RuntimeMobileSessionBrowserTab[] ): boolean { if (live.length !== existing.length) { return false } return live.every((tab, index) => { const prev = existing[index] return ( tab.id === prev.id && tab.title === prev.title && tab.url === prev.url && tab.isActive === prev.isActive && (tab.isPinned ?? false) === (prev.isPinned ?? false) && (tab.color ?? null) === (prev.color ?? null) && this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) ) }) } private browserLoadErrorsEqual( a: RuntimeMobileSessionBrowserTab['loadError'], b: RuntimeMobileSessionBrowserTab['loadError'] ): boolean { const left = a ?? null const right = b ?? null if (left === right) { return true } if (!left || !right) { return false } return ( left.code === right.code && left.description === right.description && left.validatedUrl === right.validatedUrl ) } private browserCertificateFailuresEqual( a: RuntimeMobileSessionBrowserTab['certificateFailure'], b: RuntimeMobileSessionBrowserTab['certificateFailure'] ): boolean { const left = a ?? null const right = b ?? null if (left === right) { return true } if (!left || !right) { return false } return ( left.challengeId === right.challengeId && left.browserPageId === right.browserPageId && left.errorCode === right.errorCode && left.error === right.error && left.origin === right.origin && left.displayHost === right.displayHost && left.canProceed === right.canProceed && left.observedAt === right.observedAt ) } private getPersistedUnifiedSessionTabProps( worktreeId: string, tabId: string ): Pick | null { const tab = this.getWorkspaceSessionForWorktree(worktreeId)?.unifiedTabs?.[worktreeId]?.find( (candidate) => candidate.id === tabId || candidate.entityId === tabId ) ?? null return tab ? { color: tab.color, isPinned: tab.isPinned } : null } private collectPersistedTerminalLeafIds(layout: TerminalLayoutSnapshot | undefined): string[] { if (!layout) { return [] } const leafIds = new Set() const visit = (node: TerminalLayoutSnapshot['root']): void => { if (!node) { return } if (node.type === 'leaf') { if (isTerminalLeafId(node.leafId)) { leafIds.add(node.leafId) } return } visit(node.first) visit(node.second) } visit(layout.root) if (layout.activeLeafId && isTerminalLeafId(layout.activeLeafId)) { leafIds.add(layout.activeLeafId) } for (const leafId of Object.keys(layout.ptyIdsByLeafId ?? {})) { if (isTerminalLeafId(leafId)) { leafIds.add(leafId) } } return [...leafIds] } private deriveHeadlessLegacyTerminalLeafId(tabId: string): string { const hash = createHash('sha256').update(`headless-terminal-leaf:${tabId}`).digest('hex') const variant = ((Number.parseInt(hash.slice(16, 17), 16) & 0x3) | 0x8).toString(16) const leafId = [ hash.slice(0, 8), hash.slice(8, 12), `4${hash.slice(13, 16)}`, `${variant}${hash.slice(17, 20)}`, hash.slice(20, 32) ].join('-') if (!isTerminalLeafId(leafId)) { return randomUUID() } return leafId } private cloneTerminalLayoutSnapshot(layout: TerminalLayoutSnapshot): TerminalLayoutSnapshot { const cloned: TerminalLayoutSnapshot = { root: layout.root, activeLeafId: layout.activeLeafId, expandedLeafId: layout.expandedLeafId } if (layout.ptyIdsByLeafId) { cloned.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId } } if (layout.buffersByLeafId) { cloned.buffersByLeafId = { ...layout.buffersByLeafId } } if (layout.scrollbackRefsByLeafId) { cloned.scrollbackRefsByLeafId = { ...layout.scrollbackRefsByLeafId } } if (layout.titlesByLeafId) { cloned.titlesByLeafId = { ...layout.titlesByLeafId } } return cloned } private isPersistedTerminalLeafActive( session: WorkspaceSessionState, worktreeId: string, tabId: string, leafId: string, layout: TerminalLayoutSnapshot | undefined ): boolean { const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId) } private pickHeadlessActiveTerminalTab( tabs: readonly RuntimeMobileSessionTerminalTab[] ): RuntimeMobileSessionTerminalTab | null { return tabs.find((tab) => tab.isActive) ?? tabs.find((tab) => tab.parentTabId) ?? null } private collectHeadlessParentTabOrder( tabs: readonly RuntimeMobileSessionTerminalTab[] ): string[] { const order: string[] = [] const seen = new Set() for (const tab of tabs) { if (!seen.has(tab.parentTabId)) { seen.add(tab.parentTabId) order.push(tab.parentTabId) } } return order } // Why: the group tab order must follow actual creation/insertion order across // both terminals and browsers, not list terminals first. A terminal's top-level // id is its parentTabId (split leaves share one); a browser's is its own id. private collectHeadlessTopLevelTabOrder( tabs: readonly RuntimeMobileSessionSnapshotTab[] ): string[] { const order: string[] = [] const seen = new Set() for (const tab of tabs) { const topLevelId = tab.type === 'terminal' ? tab.parentTabId : tab.id if (!seen.has(topLevelId)) { seen.add(topLevelId) order.push(topLevelId) } } return order } private getHeadlessMobileSessionGroupId(worktreeId: string): string { return `headless-terminals:${worktreeId}` } private buildHeadlessMobileSessionTabGroups( worktreeId: string, tabs: readonly RuntimeMobileSessionSnapshotTab[], activeTab: RuntimeMobileSessionSnapshotTab | null, existingGroups?: readonly RuntimeMobileSessionTabGroup[], // Why: a new tab created via a specific group's "+" must land in THAT group, // not the active one — otherwise every "+" in a split funnels to one group. newTabAssignment?: { tabId: string; groupId: string } ): RuntimeMobileSessionTabGroup[] { // Why: order across terminals and browsers in their actual array order so a // tab opened after a browser tab lands to its right, not regrouped before it. const tabOrder = this.collectHeadlessTopLevelTabOrder(tabs) const topLevelOf = (tab: RuntimeMobileSessionSnapshotTab): string => tab.type === 'terminal' ? tab.parentTabId : tab.id const activeTopLevelId = (activeTab ? topLevelOf(activeTab) : null) ?? existingGroups?.[0]?.activeTabId ?? (() => { const active = tabs.find((tab) => tab.isActive) return active ? topLevelOf(active) : null })() ?? tabOrder[0] ?? null // Why: when the user has split tabs into multiple groups, preserve that // assignment across rebuilds instead of coalescing back to one group. if (existingGroups && existingGroups.length > 1) { return this.distributeHeadlessTabsAcrossGroups( existingGroups, tabOrder, activeTopLevelId, newTabAssignment ) } const groupId = existingGroups?.[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) return [ { id: groupId, activeTabId: activeTopLevelId && tabOrder.includes(activeTopLevelId) ? activeTopLevelId : (tabOrder[0] ?? null), tabOrder } ] } // Distribute live top-level tabs into the existing multi-group structure, // keeping each tab in its group; tabs new since the last snapshot join the // active group. Emptied groups are dropped so a closed split collapses. private distributeHeadlessTabsAcrossGroups( existingGroups: readonly RuntimeMobileSessionTabGroup[], tabOrder: readonly string[], activeTopLevelId: string | null, newTabAssignment?: { tabId: string; groupId: string } ): RuntimeMobileSessionTabGroup[] { const groupIdByTabId = new Map() for (const group of existingGroups) { for (const tabId of group.tabOrder) { groupIdByTabId.set(tabId, group.id) } } // Why: route a freshly-created tab to the group its "+" was clicked in, // when that group still exists; otherwise fall through to the active group. const hasTargetGroup = newTabAssignment !== undefined && existingGroups.some((group) => group.id === newTabAssignment.groupId) if (hasTargetGroup) { groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId) } const activeGroupId = (activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? existingGroups[0]!.id const orderByGroup = new Map(existingGroups.map((group) => [group.id, []])) for (const tabId of tabOrder) { const groupId = groupIdByTabId.get(tabId) ?? activeGroupId orderByGroup.get(groupId)?.push(tabId) } return existingGroups .map((group) => { const nextOrder = orderByGroup.get(group.id) ?? [] return { ...group, tabOrder: nextOrder, activeTabId: activeTopLevelId && nextOrder.includes(activeTopLevelId) ? activeTopLevelId : group.activeTabId && nextOrder.includes(group.activeTabId) ? group.activeTabId : (nextOrder[0] ?? null) } }) .filter((group) => group.tabOrder.length > 0) } private buildMaterializedHeadlessParentLayout( leafId: string, ptyId: string, existingLayout: TerminalLayoutSnapshot | undefined, split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } ): TerminalLayoutSnapshot { if (!existingLayout) { return { root: { type: 'leaf', leafId }, activeLeafId: leafId, expandedLeafId: null, ptyIdsByLeafId: { [leafId]: ptyId } } } // Why: a split must insert the new leaf into the live layout tree with the // requested direction, or the published snapshot keeps the old single-leaf // root and the split renders with a fallback direction ("Split Right" lands // as a top/bottom split). Reuse the persisted-split builder for parity. if (split) { return buildHeadlessTerminalSplitLayout(this.cloneTerminalLayoutSnapshot(existingLayout), { leafId, ptyId, splitFromLeafId: split.splitFromLeafId, direction: split.direction }) } return { ...this.cloneTerminalLayoutSnapshot(existingLayout), ptyIdsByLeafId: { ...existingLayout.ptyIdsByLeafId, [leafId]: ptyId } } } private removePersistedHeadlessTerminalTab( worktreeId: string, parentTabId: string, options: { allowMissing?: boolean } = {} ): string[] { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { throw new Error('workspace_session_unavailable') } const result = closeTerminalTabInWorkspaceSession(session, worktreeId, parentTabId) if (result.pinned) { throw new Error('terminal_tab_pinned') } if (!result.closed) { if (options.allowMissing) { return [] } throw new Error('tab_not_found') } this.setWorkspaceSessionForWorktree( worktreeId, advanceTerminalTopologyRevision(result.session, worktreeId) ) return result.ptyIdsToKill } private persistHeadlessTerminalTabOrder(worktreeId: string, tabOrder: readonly string[]): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } const orderIndexByTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) const tabs = session.tabsByWorktree[worktreeId] ?? [] const reordered = [...tabs] .sort((a, b) => { const aIndex = orderIndexByTabId.get(a.id) ?? Number.MAX_SAFE_INTEGER const bIndex = orderIndexByTabId.get(b.id) ?? Number.MAX_SAFE_INTEGER return aIndex - bIndex || a.sortOrder - b.sortOrder || a.createdAt - b.createdAt }) .map((tab, index) => ({ ...tab, sortOrder: index })) this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: reordered } }) } private emitMobileSessionTabsSnapshot(snapshot: RuntimeMobileSessionTabsSnapshot): void { if (this.mobileSessionTabListeners.size === 0) { return } const result = this.toMobileSessionTabsResult(snapshot) for (const subscription of this.mobileSessionTabListeners) { subscription.listener( this.clientSessionTabSelections.project(result, subscription.clientNavigationId) ) } } private async refreshMobileSessionPtyRecords( targetWorktreeId: string | null = null ): Promise | null> { if (targetWorktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { const pending = this.pendingMobileSessionPtyInventoryRefresh if (pending) { return pending } // Why: reconnect exit bursts share one authoritative daemon inventory // instead of multiplying a full cross-generation list RPC per stale tab. const refresh = this.performMobileSessionPtyRecordsRefresh(targetWorktreeId).finally(() => { if (this.pendingMobileSessionPtyInventoryRefresh === refresh) { this.pendingMobileSessionPtyInventoryRefresh = null } }) this.pendingMobileSessionPtyInventoryRefresh = refresh return refresh } return await this.performMobileSessionPtyRecordsRefresh(targetWorktreeId) } private async performMobileSessionPtyRecordsRefresh( targetWorktreeId: string | null ): Promise | null> { if (!this.ptyController?.listProcesses && !this.ptyController?.hasPty) { return null } // Why: floating PTY identity is explicit, so polling must not resolve every Git/SSH worktree. const isFloatingWorkspace = targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID const resolvedWorktrees = isFloatingWorkspace ? [] : await this.listResolvedWorktrees() return await this.refreshPtyWorktreeRecordsFromController( resolvedWorktrees, isFloatingWorkspace ? targetWorktreeId : null ) } async activateMobileSessionTab( worktreeSelector: string, tabId: string, leafId?: string, opts: { notifyClients?: boolean clientNavigationId?: string navigation?: RuntimeNavigationTarget intent?: TabActivationIntent } = {} ): Promise { const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') const targetsHost = navigationTargetsHost(navigation) const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) await this.refreshMobileSessionPtyRecords(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const directTab = snapshot?.tabs.find((candidate) => candidate.id === tabId) const tab = leafId ? ((directTab?.type === 'terminal' && directTab.leafId === leafId ? directTab : undefined) ?? snapshot?.tabs.find( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId && candidate.leafId === leafId )) : (directTab ?? snapshot?.tabs.find( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId ) ?? snapshot?.tabs.find( (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId )) if (!tab) { throw new Error('tab_not_found') } if (tab.type === 'terminal') { const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find( (candidate) => candidate.type === 'terminal' && candidate.id === tab.id ) // Why: serve-created tabs can be visible before any renderer has adopted // their tab id, so focusing the renderer would silently no-op. // Phone-local activation also needs this path for inactive restored tabs: // desktop focus is intentionally suppressed, but the PTY still must exist. const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && // Why: opening a tab is the documented wake gesture for a slept pane // (#11598), so only a background probe may be refused for one. (!isAutomaticTabActivation(opts.intent) || !this.isDeliberatelyParkedPane(worktreeId, tab)) && (!targetsHost || !this.notifier?.focusTerminal || this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined const targetGroupId = snapshot?.tabGroups?.find((group) => group.tabOrder.includes(tab.parentTabId) )?.id // Why: a pending agent tab may exist without its startup command ever // having been delivered (the create's renderer stalled, #7587), so a // bare materialize would put a plain shell under the agent icon. // Re-resolve the launch like the create path; providers skip startup // commands when attaching to live sessions, so this cannot double-launch. let agentStartup: Awaited< ReturnType > = {} if (tab.launchAgent) { try { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) agentStartup = await this.resolveMobileSessionTerminalCommand(workspace, { agent: tab.launchAgent }) } catch { // Why: a disabled or unresolvable agent must not make the tab // untappable; fall back to the plain-shell materialize. } } try { await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, targetsHost, undefined, { identity: { tabId: tab.parentTabId, leafId: tab.leafId, sessionId }, cwd: tab.startupCwd, command: agentStartup.command, env: agentStartup.env, startupCommandDelivery: agentStartup.startupCommandDelivery, launchConfig: agentStartup.launchConfig, launchAgent: tab.launchAgent, targetGroupId }) } catch (err) { if (sessionId && parseAppSshPtyId(sessionId)) { // Why: an expired SSH reattach clears durable bindings in the store, // but this in-memory headless snapshot can still carry the old id. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { force: true }) } throw err } return this.applyMobileSessionTabNavigation( this.getMobileSessionTabsForWorktree(worktreeId), tab.id, navigation, opts.clientNavigationId ) } const callerSnapshot = this.getMobileSessionTabsForWorktree( worktreeId, opts.clientNavigationId ) const activeSibling = tab.id === tabId || leafId ? null : (callerSnapshot.tabs.find( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId && candidate.isActive ) as RuntimeMobileSessionTerminalTab | undefined) const targetTab = activeSibling ?? tab if (targetsHost && !this.notifier?.focusTerminal) { if ( !targetTab.isActive && this.shouldPersistHeadlessMobileSessionActivation(snapshot!, targetTab) ) { this.activateHeadlessMobileSessionTerminalTab(worktreeId, snapshot!, targetTab) } } else if (targetsHost) { this.notifier?.focusTerminal?.(targetTab.parentTabId, worktreeId, targetTab.leafId) } return this.applyMobileSessionTabNavigation( this.getMobileSessionTabsForWorktree(worktreeId), targetTab.id, navigation, opts.clientNavigationId ) } else if (tab.type === 'browser') { // Why: browser mobile tabs are renderer-owned unified tabs; focusing the // session tab keeps desktop tab order/group state authoritative. if (targetsHost) { this.notifier?.focusEditorTab?.(tab.id, worktreeId) } } else { if (targetsHost) { this.notifier?.focusEditorTab?.(tab.id, worktreeId) } } return this.applyMobileSessionTabNavigation( this.getMobileSessionTabsForWorktree(worktreeId), tab.id, navigation, opts.clientNavigationId ) } private applyMobileSessionTabNavigation( snapshot: RuntimeMobileSessionTabsResult, activeTabId: string, navigation: RuntimeNavigationTarget, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { let callerSnapshot: RuntimeMobileSessionTabsResult | null = null if (navigationTargetsClients(navigation)) { // Why: follow is live intent; disconnected devices must not inherit stale navigation on reconnect. const ids = new Set( [...this.mobileSessionTabListeners] .map((subscription) => subscription.clientNavigationId) .filter((id): id is string => Boolean(id)) ) if (clientNavigationId) { ids.add(clientNavigationId) } for (const id of ids) { const projected = this.clientSessionTabSelections.activate(snapshot, id, activeTabId) this.emitMobileSessionTabsSnapshotToClient(projected, id, true) if (id === clientNavigationId) { callerSnapshot = projected } } } else if (clientNavigationId) { // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. callerSnapshot = this.clientSessionTabSelections.activate( snapshot, clientNavigationId, activeTabId ) this.emitMobileSessionTabsSnapshotToClient(callerSnapshot, clientNavigationId) } if (clientNavigationId) { return callerSnapshot ?? this.clientSessionTabSelections.project(snapshot, clientNavigationId) } if (navigation === 'caller') { const selection = activateClientSessionTabSelection( snapshot, deriveClientSessionTabSelection(snapshot), activeTabId ) return projectClientSessionTabSelection(snapshot, selection).snapshot } return snapshot } /** * Whether persistence proves this pane's PTY was deliberately taken down and parked * (workspace sleep or completed-agent hibernation) rather than lost and awaiting reconnect. * Why: `pending-handle` alone cannot tell those apart — a parked pane publishes it * indefinitely — and respawning a parked pane re-launches its agent behind the user. * Only an automatic activation consults this; a user opening the tab is the wake gesture. */ private isDeliberatelyParkedPane( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): boolean { const record = this.getWorkspaceSessionForWorktree(worktreeId)?.sleepingAgentSessionsByPaneKey?.[ makePaneKey(tab.parentTabId, tab.leafId) ] // Why: 'live'/'quit' captures describe a pane that was still running, so a reconnect // must still mint its replacement PTY (#11542). Only a worktree-owned capture records // a deliberate takedown the user did not ask to undo. return ( record?.origin === 'worktree-sleep' && runtimeWorktreeIdsEqual(record.worktreeId, worktreeId) ) } private shouldMaterializeHeadlessMobileSessionTab( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab ): boolean { return ( this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || this.hasServeOrSshOwnedBinding(tab) ) } private shouldPersistHeadlessMobileSessionActivation( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab ): boolean { if (snapshot.publicationEpoch.includes(':headless-merge:')) { return false } if (this.authoritativeWindowId !== null && this.graphStatus === 'ready') { return false } return this.shouldMaterializeHeadlessMobileSessionTab(snapshot, tab) } private activateHeadlessMobileSessionTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, activeTab: RuntimeMobileSessionTerminalTab ): void { const tabs = snapshot.tabs.map((candidate) => ({ ...candidate, isActive: candidate.id === activeTab.id })) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: activeTab.id, activeTabType: 'terminal', tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, tabs, activeTab, snapshot.tabGroups ), tabs } this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab) this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } // Why: a headless split only updated the LIVE session snapshot, never the // persisted workspace session layout. So a later snapshot rebuild (e.g. on the // next terminal create) re-derived from the stale single-leaf persisted layout // and collapsed the split. Persist the new split leaf into the workspace // session's terminalLayoutsByTabId so the split survives rebuilds. private persistHeadlessTerminalSplit(args: { worktreeId: string tabId: string leafId: string ptyId: string splitFromLeafId: string direction: 'horizontal' | 'vertical' }): void { const session = this.getWorkspaceSessionForWorktree(args.worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } const existing = session.terminalLayoutsByTabId?.[args.tabId] const nextLayout = buildHeadlessTerminalSplitLayout( existing ? this.cloneTerminalLayoutSnapshot(existing) : undefined, args ) this.setWorkspaceSessionForWorktree(args.worktreeId, { ...session, terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, [args.tabId]: nextLayout } }) } private persistHeadlessTerminalActiveLeaf( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } const existingLayout = session.terminalLayoutsByTabId?.[tab.parentTabId] const nextLayouts = existingLayout ? { ...session.terminalLayoutsByTabId, [tab.parentTabId]: { ...this.cloneTerminalLayoutSnapshot(existingLayout), activeLeafId: tab.leafId } } : session.terminalLayoutsByTabId this.setWorkspaceSessionForWorktree(worktreeId, { ...session, activeTabId: tab.parentTabId, activeTabIdByWorktree: { ...session.activeTabIdByWorktree, [worktreeId]: tab.parentTabId }, terminalLayoutsByTabId: nextLayouts }) } async refuseUnattributedMobileSessionTabClose( worktreeSelector: string, tabId: string ): Promise { const snapshot = await this.listMobileSessionTabs(worktreeSelector) const tabExists = snapshot.tabs.some( (candidate) => candidate.id === tabId || (candidate.type === 'terminal' && candidate.parentTabId === tabId) || (candidate.type === 'browser' && candidate.browserWorkspaceId === tabId) ) if (!tabExists) { throw new Error('tab_not_found') } // Why: a legacy client may already have hidden its mirror; a new snapshot // restores it without granting an unattributed request destructive authority. this.republishMobileSessionTabsSnapshot(snapshot.worktree) return { closed: true, refused: true, refusalReason: 'missing-intent', snapshotRepublished: true } } async closeMobileSessionTab( worktreeSelector: string, tabId: string, options: { reason?: RuntimeSessionTabCloseReason expectedPublicationEpoch?: string expectedTerminalHandle?: string clientNavigationId?: string } = {} ): Promise { const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() if (graphEpoch !== null) { this.assertStableReadyGraph(graphEpoch) } this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) { // Why: keep-on-unknown must also restore the mirror the caller already pruned. this.republishMobileSessionTabsSnapshot(worktreeId) return { closed: true, refused: true, refusalReason: 'unknown-liveness', ...(snapshot ? { snapshotRepublished: true as const } : {}) } } if ( options.expectedPublicationEpoch !== undefined && snapshot?.publicationEpoch !== options.expectedPublicationEpoch ) { this.republishMobileSessionTabsSnapshot(worktreeId) return { closed: true, refused: true, refusalReason: 'stale-publication', ...(snapshot ? { snapshotRepublished: true as const } : {}) } } const tab = snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? snapshot?.tabs.find( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId ) ?? snapshot?.tabs.find( (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId ) if (!tab) { throw new Error('tab_not_found') } if (options.expectedTerminalHandle !== undefined) { const terminalIncarnationMatches = tab.type === 'terminal' && snapshot!.tabs.some( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId && this.getMobileSessionTerminalHandle(worktreeId, candidate) === options.expectedTerminalHandle ) if (!terminalIncarnationMatches) { this.republishMobileSessionTabsSnapshot(worktreeId) return { closed: true, refused: true, refusalReason: 'stale-terminal', snapshotRepublished: true } } } if (tab.type === 'terminal') { const parentLeafCount = snapshot!.tabs.filter( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ).length const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 // Why: a non-'user' reason is a client-lifecycle echo ("terminal gone"), // not authorization to kill. Every destructive branch below can take the // whole parent down, so any live PTY under the parent means the echo is a // transport artifact: refuse the close and republish the snapshot so the // echoing client re-syncs and re-attaches. A reasonless close keeps // legacy behavior — old clients send user closes without the field. if (options.reason !== undefined && options.reason !== 'user') { const parentLeaves = snapshot!.tabs.filter( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ) // Why: exited PTYs keep a disconnected record in ptysById for status // reads (and a still-synced leaf retains its record), so record // presence is not liveness — only `connected` counts, or a genuinely // dead tab never retires and the echo loops forever. const leafHasConnectedPty = (leaf: RuntimeMobileSessionTerminalTab): boolean => { const snapshotPtyIds = [ leaf.ptyId, leaf.parentLayout?.ptyIdsByLeafId?.[leaf.leafId] ].filter((ptyId): ptyId is string => Boolean(ptyId)) // Why: daemon discovery can prove the PTY live before its pane binding // reconnects; missing metadata is never authority to retire it. return ( this.findPtyForMobileTerminalTab(worktreeId, leaf)?.connected === true || snapshotPtyIds.some((ptyId) => observedPtyIds?.has(ptyId) === true) ) } if (parentLeaves.some(leafHasConnectedPty)) { // Why: when the echo addresses a dead leaf under a live sibling we // still refuse (every reachable close path below destroys the whole // parent, live sibling included) but skip the republish — re-adding // the dead leaf on the echoing client would feed an endless // refuse→republish→re-echo cycle. const addressedDeadLeaf = tab.id === tabId && !leafHasConnectedPty(tab) if (!addressedDeadLeaf) { this.republishMobileSessionTabsSnapshot(worktreeId) } // Why: both markers are skew-safe; clients must restore a mirror only // when the host actually republished it, not for a dead leaf. return { closed: true, refused: true, refusalReason: 'live-host-pty', ...(!addressedDeadLeaf ? { snapshotRepublished: true as const } : {}) } } if (!closingWholeParent || this.tabs.has(tab.parentTabId)) { // Why: only the renderer may retire its own tab or split leaf; a // remote lifecycle echo must never cross that boundary into a kill. return { closed: true, refused: true, refusalReason: 'retirement-owner' } } } // Why: a runtime-owned headless tab is absent from renderer state, so the // closeTerminalTab relay below would ack success without killing its PTY, // and syncMobileSessionTabs would republish the "closed" tab. Only bypass // the relay when no renderer owns the parent: an adopted tab needs the // renderer's live pin guard and durable close transaction. if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab, { killPtys: options.reason === undefined || options.reason === 'user' }) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } if (closingWholeParent && this.notifier?.closeTerminalTab) { // Why: whole-tab close is a lifecycle transaction. The renderer reply // arrives only after canonical retirement and a forced session flush. const win = this.getAvailableAuthoritativeWindow() if (win?.webContents.isDestroyed?.()) { throw new Error('runtime_unavailable') } const releasePublicationThrottle = options.clientNavigationId && win ? this.rendererPublicationThrottle.acquire(win.webContents) : () => {} try { await this.notifier.closeTerminalTab(tab.parentTabId) } finally { releasePublicationThrottle() } const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const remainingTab = remainingSnapshot?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ) if ( remainingSnapshot && remainingTab && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab) ) { // Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface. this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, { // Why: the renderer may already have durably removed the tab before acknowledging. allowMissingPersistedTab: true }) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId) return { closed: true } } // Why: notifier implementations without the acknowledged relay may expose // only raw pane close. Runtime-owned parents still need de-persist + kill. if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) this.store?.flushOrThrow?.() return { closed: true } } if (tab.id === tabId) { const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) if (pty) { this.ptyController?.kill(pty.ptyId) } else { this.notifier?.closeTerminal(tab.parentTabId) } } else { // Why: paired web tab bars represent a split terminal with one local // parent tab id. Closing that parent should close the desktop tab, not // just whichever leaf happened to be first in the session snapshot. this.notifier?.closeTerminal(tab.parentTabId) this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot!, tab.parentTabId) } } else if (tab.type === 'browser' && this.offscreenBrowserBackend) { // Why: headless browser tabs are offscreen WebContents with no renderer to // route closeSessionTab to. Close the page directly and drop it from the // snapshot so paired clients stop showing it. await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot!, tab) } else { this.notifier?.closeSessionTab?.(tab.id, worktreeId) } return { closed: true } } // Why: a refused echoed close means the echoing client already pruned its // local mirror. Bump the version and emit the unchanged snapshot so clients // that dedupe by snapshotVersion re-add and re-attach the still-live tab. private republishMobileSessionTabsSnapshot(worktreeId: string): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (snapshot) { this.mobileSessionTabsByWorktree.set(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) } this.notifyMobileSessionTabsChanged(worktreeId) } private getMobileSessionTerminalHandle( worktreeId: string, tab: RuntimeMobileSessionTerminalTab ): string | null { const pty = this.findPtyForMobileTerminalTab(worktreeId, tab) if (!pty) { return null } return this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) } private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { // Why: this relay is advisory after main owns teardown; renderer failure must // not prevent the authoritative session flush or turn the close into failure. try { this.notifier?.closeTerminal(parentTabId) } catch (error) { console.warn('[runtime] failed to notify renderer after headless terminal close', { parentTabId, error }) } } private async closeHeadlessMobileBrowserTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionBrowserTab ): Promise { if (tab.browserPageId) { await this.offscreenBrowserBackend?.closeTab(tab.browserPageId).catch(() => {}) } const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ ...group, tabOrder: group.tabOrder.filter((id) => id !== tab.id), activeTabId: group.activeTabId === tab.id ? null : group.activeTabId })), tabs: nextTabs } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } private markHeadlessBrowserSessionTabActive( worktreeId: string | undefined, browserPageId: string, targetGroupId?: string ): void { if (!this.offscreenBrowserBackend || !worktreeId) { return } // Hydrate first so the freshly created browser tab is present in the snapshot. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( (candidate): candidate is RuntimeMobileSessionBrowserTab => candidate.type === 'browser' && candidate.browserPageId === browserPageId ) if (!snapshot || !tab) { return } const groups = snapshot.tabGroups ?? [] const hasTargetGroup = targetGroupId !== undefined && groups.some((group) => group.id === targetGroupId) // Why: move the new browser into the group whose "+" was clicked, removing it // from wherever the rebuild placed it. Only the TARGET group's activeTabId // (and the global active) change — every other group's active tab is left // intact, so creating in the right group never resets the left group's tab. const nextGroups = hasTargetGroup ? groups.map((group) => { const withoutTab = group.tabOrder.filter((id) => id !== tab.id) if (group.id === targetGroupId) { return { ...group, tabOrder: [...withoutTab, tab.id], activeTabId: tab.id } } return withoutTab.length === group.tabOrder.length ? group : { ...group, tabOrder: withoutTab } }) : groups.map((group) => group.tabOrder.includes(tab.id) ? { ...group, activeTabId: tab.id } : group ) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, ...(hasTargetGroup ? { activeGroupId: targetGroupId } : {}), activeTabId: tab.id, activeTabType: 'browser', tabs: snapshot.tabs.map((candidate) => ({ ...candidate, isActive: candidate.id === tab.id })), tabGroups: nextGroups } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) // Why: browser group membership is otherwise live-only; persist it so a // later rebuild keeps the browser in its group instead of coalescing left. if (hasTargetGroup && nextSnapshot.tabGroupLayout) { this.persistHeadlessTabGroups(worktreeId, nextGroups, nextSnapshot.tabGroupLayout) } this.emitMobileSessionTabsSnapshot(nextSnapshot) } private closeHeadlessMobileTerminalTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionTerminalTab, options: { allowMissingPersistedTab?: boolean; killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId) const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, { allowMissing: options.allowMissingPersistedTab }) // Why: local provider ids can be reused after restart, so a dormant // persisted id is not kill authority. SSH relay ids remain durable exact // identities even before pane metadata reconnects. const ptyIdsToKill = new Set(projectedPtyIds.filter((ptyId) => parseAppSshPtyId(ptyId))) for (const candidate of snapshot.tabs) { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { continue } const livePty = this.findPtyForMobileTerminalTab(worktreeId, candidate) const ptyId = livePty?.ptyId ?? candidate.ptyId const hasOtherOwner = snapshot.tabs.some( (other) => other.type === 'terminal' && other.parentTabId !== closedParentTabId && other.ptyId === ptyId ) if (ptyId && !hasOtherOwner && (livePty || parseAppSshPtyId(ptyId))) { // Why: a live serve leaf can exist before its debounced binding reaches // persistence. Include it from the authoritative snapshot so split // close cannot leave a provider process behind. ptyIdsToKill.add(ptyId) } } if (options.killPtys !== false) { for (const ptyId of ptyIdsToKill) { this.ptyController?.kill(ptyId) } } const nextTabs = snapshot.tabs.filter((candidate) => { if (candidate.type !== 'terminal' || candidate.parentTabId !== closedParentTabId) { return true } return false }) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, nextTabs, active, snapshot.tabGroups ), tabs: nextTabs } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } async moveMobileSessionTab( worktreeSelector: string, move: RuntimeMobileSessionTabMove ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { throw new Error('tab_not_found') } if (!this.notifier?.moveSessionTab) { return this.moveHeadlessMobileSessionTab(worktreeId, snapshot, move) } const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) if (!hostTabId) { throw new Error('tab_not_found') } const publicSnapshot = this.toMobileSessionTabsResult(snapshot) const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) if (!targetGroup) { throw new Error('target_group_not_found') } // Why: web clients address terminal surfaces as tab::leaf, while desktop // tab grouping is owned by the outer terminal tab id. if (move.kind === 'reorder') { const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) if (!tabOrder.includes(hostTabId)) { throw new Error('invalid_tab_order') } this.notifier.moveSessionTab(worktreeId, { ...move, tabId: hostTabId, tabOrder }) return { moved: true } } this.notifier.moveSessionTab(worktreeId, { ...move, tabId: hostTabId }) return { moved: true } } // Why: pane geometry inside a tab (split ratios, expanded pane, pane titles) // is host-authoritative for remote-server tabs but had no push path, so a // client divider-drag / expand / pane-rename reverted on the next snapshot. // Persist the structural fields onto the tab's layout, keeping host-owned // pty bindings and active leaf. async updateMobileSessionPaneLayout( worktreeSelector: string, args: { tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null titlesByLeafId?: Record } ): Promise<{ updated: true }> { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id // Why: when a renderer is authoritative (desktop host reached via shared // control), it owns pane geometry and republishes it — a headless write here // would be overwritten and could fight the renderer. Persist only headlessly. if (this.getAvailableAuthoritativeWindow()) { return { updated: true } } // Why: resolve to the host tab id (older/raw-id clients) so the persisted // layout entry matches, matching setMobileSessionTabProps. const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const hostTabId = snapshot ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) : args.tabId const resolvedArgs = { ...args, tabId: hostTabId } const acceptedLayout = this.persistHeadlessTerminalPaneLayout(worktreeId, resolvedArgs) if (acceptedLayout) { this.applyHeadlessTerminalPaneLayoutToSnapshot(worktreeId, { tabId: hostTabId, root: acceptedLayout.root, expandedLeafId: acceptedLayout.expandedLeafId, ...(acceptedLayout.titlesByLeafId ? { titlesByLeafId: acceptedLayout.titlesByLeafId } : {}) }) } return { updated: true } } // Why: tab color/pin are host-authoritative for remote-server tabs but had no // push path, so pinning or coloring a tab reverted on the next snapshot and // was never persisted. Persist to the workspace session + live snapshot. async setMobileSessionTabProps( worktreeSelector: string, args: { tabId: string color?: string | null isPinned?: boolean viewMode?: 'terminal' | 'chat' } ): Promise<{ updated: true }> { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id // Why: a renderer-authoritative host owns + republishes tab props, so a // headless write would be overwritten. Persist only when headless. if (this.getAvailableAuthoritativeWindow()) { return { updated: true } } const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const hostTabId = snapshot ? (this.resolveMobileSessionHostTabId(snapshot, args.tabId) ?? args.tabId) : args.tabId this.persistHeadlessSessionTabProps(worktreeId, hostTabId, args) this.applyHeadlessSessionTabPropsToSnapshot(worktreeId, hostTabId, args) return { updated: true } } private persistHeadlessSessionTabProps( worktreeId: string, tabId: string, props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } const tabs = session.tabsByWorktree[worktreeId] const nextSession: WorkspaceSessionState = { ...session } let changed = false if (tabs?.some((tab) => tab.id === tabId)) { changed = true nextSession.tabsByWorktree = { ...session.tabsByWorktree, [worktreeId]: tabs.map((tab) => tab.id === tabId ? { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) } : tab ) } } const unifiedTabs = session.unifiedTabs?.[worktreeId] if (unifiedTabs?.some((tab) => tab.id === tabId || tab.entityId === tabId)) { changed = true nextSession.unifiedTabs = { ...session.unifiedTabs, [worktreeId]: unifiedTabs.map((tab) => tab.id === tabId || tab.entityId === tabId ? { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}) } : tab ) } } if (!changed) { return } this.setWorkspaceSessionForWorktree(worktreeId, nextSession) } private applyHeadlessSessionTabPropsToSnapshot( worktreeId: string, tabId: string, props: { color?: string | null; isPinned?: boolean; viewMode?: 'terminal' | 'chat' } ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { return } let changed = false const tabs = snapshot.tabs.map((tab) => { if (this.getMobileSessionTopLevelTabId(tab) !== tabId) { return tab } changed = true return { ...tab, ...(props.color !== undefined ? { color: props.color } : {}), ...(props.isPinned !== undefined ? { isPinned: props.isPinned } : {}), ...(props.viewMode !== undefined ? { viewMode: props.viewMode } : {}) } }) if (!changed) { return } const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, tabs } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } private getMobileSessionTopLevelTabId(tab: RuntimeMobileSessionSnapshotTab): string { return tab.type === 'terminal' ? tab.parentTabId : tab.id } // Merge the client's pane structure into the persisted tab layout. PTY // bindings and active leaf stay host-owned; only ratios/expand/titles change. // terminalLayoutsByTabId is keyed by tab id (worktree-independent). private persistHeadlessTerminalPaneLayout( worktreeId: string, args: { tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null titlesByLeafId?: Record } ): TerminalLayoutSnapshot | undefined { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return undefined } const existing = session.terminalLayoutsByTabId?.[args.tabId] if (!existing) { return undefined } const candidate = { ...session, terminalLayoutsByTabId: { ...session.terminalLayoutsByTabId, [args.tabId]: { ...this.cloneTerminalLayoutSnapshot(existing), root: args.root ?? existing.root, expandedLeafId: args.expandedLeafId, ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } } this.setWorkspaceSessionForWorktree(worktreeId, candidate) // Why: persistence may reject stale membership while accepting its metadata; publish only that rebased layout. return ( this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[args.tabId] ?? candidate.terminalLayoutsByTabId[args.tabId] ) } private applyHeadlessTerminalPaneLayoutToSnapshot( worktreeId: string, args: { tabId: string root: TerminalPaneLayoutNode | null expandedLeafId: string | null titlesByLeafId?: Record } ): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { return } let changed = false const tabs = snapshot.tabs.map((tab) => { if (tab.type !== 'terminal' || tab.parentTabId !== args.tabId || !tab.parentLayout) { return tab } changed = true return { ...tab, parentLayout: { ...tab.parentLayout, root: args.root ?? tab.parentLayout.root, expandedLeafId: args.expandedLeafId, ...(args.titlesByLeafId ? { titlesByLeafId: args.titlesByLeafId } : {}) } } }) if (!changed) { return } const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, tabs } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } private moveHeadlessMobileSessionTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, move: RuntimeMobileSessionTabMove ): RuntimeMobileSessionTabMoveResult { if (move.kind === 'split') { return this.splitHeadlessMobileSessionTabGroup(worktreeId, snapshot, move) } if (move.kind === 'move-to-group') { return this.moveHeadlessMobileSessionTabToGroup(worktreeId, snapshot, move) } if (move.kind !== 'reorder') { throw new Error('renderer_unavailable') } const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) if (!hostTabId) { throw new Error('tab_not_found') } const publicSnapshot = this.toMobileSessionTabsResult(snapshot) const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) if (!targetGroup) { throw new Error('target_group_not_found') } const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) const nextTabs = [...snapshot.tabs].sort((a, b) => { const aParent = a.type === 'terminal' ? a.parentTabId : a.id const bParent = b.type === 'terminal' ? b.parentTabId : b.id const aIndex = orderIndexByParentTabId.get(aParent) ?? Number.MAX_SAFE_INTEGER const bIndex = orderIndexByParentTabId.get(bParent) ?? Number.MAX_SAFE_INTEGER return aIndex - bIndex }) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null const reorderedTargetActiveTabId = active?.type === 'terminal' ? active.parentTabId : active ? active.id : (tabOrder[0] ?? null) // Why: reorder only changes ONE group's order. Preserve every other group so // a multi-group split isn't deleted by re-sorting tabs in one of its groups. const existingGroups = snapshot.tabGroups ?? [] const nextGroups = existingGroups.some((group) => group.id === targetGroup.id) ? existingGroups.map((group) => group.id === targetGroup.id ? { ...group, tabOrder, activeTabId: reorderedTargetActiveTabId } : group ) : [{ ...targetGroup, tabOrder, activeTabId: reorderedTargetActiveTabId }] const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, tabGroups: nextGroups, tabs: nextTabs } this.persistHeadlessTerminalTabOrder(worktreeId, tabOrder) if (nextGroups.length > 1 && snapshot.tabGroupLayout) { this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) } this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } // Why: a drag-to-split-group used to be a client-only change the headless host // never modeled, so the next snapshot coalesced every tab back into one group. // Model + persist the multi-group layout so the split survives rebuilds. private splitHeadlessMobileSessionTabGroup( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, move: Extract ): RuntimeMobileSessionTabMoveResult { const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) if (!hostTabId) { throw new Error('tab_not_found') } const split = buildHeadlessTabGroupSplit({ groups: snapshot.tabGroups ?? [], layout: snapshot.tabGroupLayout, tabId: hostTabId, targetGroupId: move.targetGroupId, splitDirection: move.splitDirection, newGroupId: randomUUID() }) if (!split) { // Renderer treats an unsplittable drop (e.g. last tab onto its own group) // as a no-op; mirror that instead of churning the snapshot. return { moved: true } } const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeGroupId: split.newGroupId, tabGroups: split.groups, tabGroupLayout: split.layout } this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout) this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } // Move a tab into an existing group on a headless serve (non-split drop). private moveHeadlessMobileSessionTabToGroup( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, move: Extract ): RuntimeMobileSessionTabMoveResult { const hostTabId = this.resolveMobileSessionHostTabId(snapshot, move.tabId) if (!hostTabId) { throw new Error('tab_not_found') } const moved = buildHeadlessTabGroupMove({ groups: snapshot.tabGroups ?? [], layout: snapshot.tabGroupLayout, tabId: hostTabId, targetGroupId: move.targetGroupId, index: move.index }) if (!moved) { // Same-group / missing-target drop is a renderer no-op; mirror that. return { moved: true } } const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeGroupId: move.targetGroupId, tabGroups: moved.groups, tabGroupLayout: layout } this.persistHeadlessTabGroups(worktreeId, moved.groups, layout) this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split. private persistHeadlessTabGroups( worktreeId: string, groups: readonly RuntimeMobileSessionTabGroup[], layout: TabGroupLayoutNode ): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabGroups: { ...session.tabGroups, [worktreeId]: groups.map((group) => ({ id: group.id, worktreeId, activeTabId: group.activeTabId, tabOrder: [...group.tabOrder], ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) })) }, tabGroupLayouts: { ...session.tabGroupLayouts, [worktreeId]: layout } }) } // Persist a manual terminal rename so a headless rebuild keeps the title // instead of reverting to the generated/default one. private persistHeadlessTerminalTitle( worktreeId: string, tabId: string, title: string | null ): void { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session || !this.store?.setWorkspaceSession) { return } const tabs = session.tabsByWorktree[worktreeId] if (!tabs?.some((tab) => tab.id === tabId)) { return } this.setWorkspaceSessionForWorktree(worktreeId, { ...session, tabsByWorktree: { ...session.tabsByWorktree, [worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, customTitle: title } : tab)) } }) } private normalizeMobileSessionTabOrder( snapshot: RuntimeMobileSessionTabsSnapshot | undefined, targetGroup: RuntimeMobileSessionTabGroup, tabOrder: readonly string[] ): string[] { const normalized: string[] = [] const seen = new Set() for (const tabId of tabOrder) { const hostTabId = this.resolveMobileSessionHostTabId(snapshot, tabId) if (!hostTabId) { throw new Error('invalid_tab_order') } if (seen.has(hostTabId)) { throw new Error('duplicate_tab_order') } seen.add(hostTabId) normalized.push(hostTabId) } const returnedIds = this.collectPublicMobileSessionTabIds(snapshot) const expected = targetGroup.tabOrder .map((tabId) => this.resolveMobileSessionHostTabId(snapshot, tabId) ?? tabId) // Why: clients reorder the sanitized session.tabs.list model; raw groups // can still contain stale browser ids hidden from paired web clients. .filter((tabId) => returnedIds.has(tabId)) // Why: reorder is a pure permutation of one existing group. Missing or // extra ids would let a paired web client silently move/lose host tabs. if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) { throw new Error('invalid_tab_order') } return normalized } private collectPublicMobileSessionTabIds( snapshot: RuntimeMobileSessionTabsSnapshot | undefined ): Set { const ids = new Set() if (!snapshot) { return ids } const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) for (const tab of snapshot.tabs) { if (tab.type === 'browser') { const liveTab = tab.browserPageId ? liveBrowserTabsByPageId.get(tab.browserPageId) : undefined if (!liveTab) { continue } ids.add(tab.id) ids.add(tab.browserWorkspaceId) continue } ids.add(tab.id) if (tab.type === 'terminal') { ids.add(tab.parentTabId) } } return ids } private resolveMobileSessionHostTabId( snapshot: RuntimeMobileSessionTabsSnapshot | undefined, tabId: string ): string | null { const tab = snapshot?.tabs.find((candidate) => candidate.id === tabId) ?? snapshot?.tabs.find( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tabId ) ?? snapshot?.tabs.find( (candidate) => candidate.type === 'browser' && candidate.browserWorkspaceId === tabId ) if (!tab) { return null } return tab.type === 'terminal' ? tab.parentTabId : tab.id } async readMobileMarkdownTab( worktreeSelector: string, tabId: string ): Promise { const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) if (!this.notifier?.readMobileMarkdownTab) { throw new Error('renderer_unavailable') } return await this.notifier.readMobileMarkdownTab(worktreeId, tabId) } async saveMobileMarkdownTab( worktreeSelector: string, tabId: string, baseVersion: string, content: string ): Promise { const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) if (!this.notifier?.saveMobileMarkdownTab) { throw new Error('renderer_unavailable') } return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) } private readonly fileCommands = new RuntimeFileCommands({ getRuntimeId: () => this.runtimeId, requireStore: () => this.requireStore(), resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), resolveKnownWorkspaceFileTarget: (absolutePath, connectionId) => this.resolveKnownWorkspaceFileTarget(absolutePath, connectionId), resolveTerminalCwd: (terminalHandle) => this.resolveTerminalCwd(terminalHandle), resolveTerminalContext: (terminalHandle) => this.resolveTerminalContext(terminalHandle), resolveTerminalFileUriHostname: (terminalHandle) => this.resolveTerminalFileUriHostname(terminalHandle), hasRecentTerminalOutputPath: (terminalHandle, pathText, absolutePath) => this.hasRecentTerminalOutputPath(terminalHandle, pathText, absolutePath), resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId) => { if (!this.notifier?.openFile) { throw new Error('renderer_unavailable') } this.notifier.openFile(worktreeId, filePath, relativePath, runtimeEnvironmentId) }, openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) => { if (!this.notifier?.openDiff) { throw new Error('renderer_unavailable') } this.notifier.openDiff(worktreeId, filePath, relativePath, staged, runtimeEnvironmentId) } }) listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.bind( this.fileCommands ) searchMobileFilePaths: RuntimeFileCommands['searchMobileFilePaths'] = this.fileCommands.searchMobileFilePaths.bind(this.fileCommands) openMobileFile: RuntimeFileCommands['openMobileFile'] = this.fileCommands.openMobileFile.bind( this.fileCommands ) openMobileDiff: RuntimeFileCommands['openMobileDiff'] = this.fileCommands.openMobileDiff.bind( this.fileCommands ) readMobileFile: RuntimeFileCommands['readMobileFile'] = this.fileCommands.readMobileFile.bind( this.fileCommands ) resolveTerminalPath: RuntimeFileCommands['resolveTerminalPath'] = this.fileCommands.resolveTerminalPath.bind(this.fileCommands) readTerminalArtifactFile: RuntimeFileCommands['readTerminalArtifactFile'] = this.fileCommands.readTerminalArtifactFile.bind(this.fileCommands) readTerminalArtifactPreview: RuntimeFileCommands['readTerminalArtifactPreview'] = this.fileCommands.readTerminalArtifactPreview.bind(this.fileCommands) writeTerminalArtifactFile: RuntimeFileCommands['writeTerminalArtifactFile'] = this.fileCommands.writeTerminalArtifactFile.bind(this.fileCommands) revokeTerminalFileGrantsForClient: RuntimeFileCommands['revokeTerminalFileGrantsForClient'] = this.fileCommands.revokeTerminalFileGrantsForClient.bind(this.fileCommands) readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] = this.fileCommands.readFileExplorerDir.bind(this.fileCommands) watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] = this.fileCommands.watchFileExplorer.bind(this.fileCommands) closeFileWatchersForRemoval = async ( worktreePath: string, connectionId?: string, deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline() ): Promise => { // Why drain the remote/explorer closes: they await SSH round trips and lease suspends that a dead // link never answers. The local close bounds its own awaits against the same deadline internally. const results = await Promise.allSettled([ connectionId ? drainBeforeWatcherRemoval( closeRemoteWatcherForWorktreePath(connectionId, worktreePath), deadline, `remote watcher close for ${worktreePath}` ) : closeLocalWatcherForWorktreePath(worktreePath, deadline), drainBeforeWatcherRemoval( this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId), deadline, `file explorer watcher close for ${worktreePath}` ) ]) const failure = results.find((result): result is PromiseRejectedResult => { return result.status === 'rejected' }) if (failure) { // Why: restoration must start only after every bounded teardown settles; // otherwise a late close can stale a just-restored logical subscription. throw failure.reason } } restoreFileWatchersAfterFailedRemoval = async ( worktreePath: string, connectionId?: string ): Promise => { await Promise.all([ connectionId ? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath) : restoreLocalWatcherAfterFailedRemoval(worktreePath), this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId) ]) } forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => { if (connectionId) { forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath) } else { forgetLocalWatcherRemovalSnapshot(worktreePath) } this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId) } acquireFileWatcherRemoval = async ( worktreePath: string, connectionId?: string ): Promise<{ finish(removed: boolean): Promise }> => { const gate = acquireWatcherRemovalGate(worktreePath, connectionId) // Why: one budget for the whole preparation — independent per-await timeouts would compose into minutes. const deadline = createWatcherRemovalDeadline() try { // Why: the first pass aborts desktop setup immediately; the second catches // any pre-gate runtime install that published after the first snapshot. await this.closeFileWatchersForRemoval(worktreePath, connectionId, deadline) // Why: a wedged install never releases its fence slot, so gate.ready can hang forever; the delete // must proceed instead, or the gate stays held and every later install under this root is rejected. const fenceDrain = await drainBeforeWatcherRemoval( gate.ready, deadline, `watcher install fence for ${worktreePath}` ) if (fenceDrain === 'timeout') { // Why: a wedged install holds its fence slot for the process lifetime, so leaving it counted // makes every later removal of this root burn the whole drain budget again. gate.abandonPendingInstalls() } await this.closeFileWatchersForRemoval(worktreePath, connectionId, deadline) let finished = false return { finish: async (removed) => { if (finished) { return } finished = true if (removed) { this.forgetFileWatchersAfterRemoval(worktreePath, connectionId) } gate.release() if (!removed) { await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( (restoreError: unknown) => { console.error('[worktrees] failed to restore watchers after removal failed', { worktreePath, restoreError }) } ) } } } } catch (error) { gate.release() await this.restoreFileWatchersAfterFailedRemoval(worktreePath, connectionId).catch( (restoreError: unknown) => { console.error('[worktrees] failed to restore watchers after removal setup failed', { worktreePath, restoreError }) } ) throw error } } readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] = this.fileCommands.readFileExplorerPreview.bind(this.fileCommands) readFileExplorerChunk: RuntimeFileCommands['readFileExplorerChunk'] = this.fileCommands.readFileExplorerChunk.bind(this.fileCommands) writeFileExplorerFile: RuntimeFileCommands['writeFileExplorerFile'] = this.fileCommands.writeFileExplorerFile.bind(this.fileCommands) writeFileExplorerFileBase64: RuntimeFileCommands['writeFileExplorerFileBase64'] = this.fileCommands.writeFileExplorerFileBase64.bind(this.fileCommands) writeFileExplorerFileBase64Chunk: RuntimeFileCommands['writeFileExplorerFileBase64Chunk'] = this.fileCommands.writeFileExplorerFileBase64Chunk.bind(this.fileCommands) createFileExplorerFile: RuntimeFileCommands['createFileExplorerFile'] = this.fileCommands.createFileExplorerFile.bind(this.fileCommands) createFileExplorerDir: RuntimeFileCommands['createFileExplorerDir'] = this.fileCommands.createFileExplorerDir.bind(this.fileCommands) createFileExplorerDirNoClobber: RuntimeFileCommands['createFileExplorerDirNoClobber'] = this.fileCommands.createFileExplorerDirNoClobber.bind(this.fileCommands) commitFileExplorerUpload: RuntimeFileCommands['commitFileExplorerUpload'] = this.fileCommands.commitFileExplorerUpload.bind(this.fileCommands) renameFileExplorerPath: RuntimeFileCommands['renameFileExplorerPath'] = this.fileCommands.renameFileExplorerPath.bind(this.fileCommands) copyFileExplorerPath: RuntimeFileCommands['copyFileExplorerPath'] = this.fileCommands.copyFileExplorerPath.bind(this.fileCommands) deleteFileExplorerPath: RuntimeFileCommands['deleteFileExplorerPath'] = this.fileCommands.deleteFileExplorerPath.bind(this.fileCommands) searchRuntimeFiles: RuntimeFileCommands['searchRuntimeFiles'] = this.fileCommands.searchRuntimeFiles.bind(this.fileCommands) listRuntimeFiles: RuntimeFileCommands['listRuntimeFiles'] = this.fileCommands.listRuntimeFiles.bind(this.fileCommands) listRuntimeMarkdownDocuments: RuntimeFileCommands['listRuntimeMarkdownDocuments'] = this.fileCommands.listRuntimeMarkdownDocuments.bind(this.fileCommands) statRuntimeFile: RuntimeFileCommands['statRuntimeFile'] = this.fileCommands.statRuntimeFile.bind( this.fileCommands ) private readonly gitCommands = new RuntimeGitCommands({ resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings, getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined, // Why: resolved worktrees are cached for a second, so link/unlink would lag // generation; meta is keyed by the same id the resolver returns. getWorktreeLinkedIssue: (worktreeId) => { const store = this.store // Why: an unreadable store is "unknown", not "unlinked" — undefined keeps // the resolver's cached linkedIssue instead of suppressing {linkedIssue}. if (!store?.getWorktreeMeta) { return undefined } return store.getWorktreeMeta(worktreeId)?.linkedIssue ?? null }, getWorktreeLinkedIssueMeta: (worktreeId) => { const store = this.store if (!store?.getWorktreeMeta) { return undefined } const meta = store.getWorktreeMeta(worktreeId) return meta ? { linkedIssue: meta.linkedIssue, linkedGitLabIssue: meta.linkedGitLabIssue, linkedWorkItem: meta.linkedWorkItem } : null } }) getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) getRuntimeGitSubmoduleStatus: RuntimeGitCommands['getRuntimeGitSubmoduleStatus'] = this.gitCommands.getRuntimeGitSubmoduleStatus.bind(this.gitCommands) checkRuntimeGitIgnoredPaths: RuntimeGitCommands['checkRuntimeGitIgnoredPaths'] = this.gitCommands.checkRuntimeGitIgnoredPaths.bind(this.gitCommands) getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] = this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) abortRuntimeGitMerge: RuntimeGitCommands['abortRuntimeGitMerge'] = this.gitCommands.abortRuntimeGitMerge.bind(this.gitCommands) abortRuntimeGitRebase: RuntimeGitCommands['abortRuntimeGitRebase'] = this.gitCommands.abortRuntimeGitRebase.bind(this.gitCommands) checkoutRuntimeGitBranch: RuntimeGitCommands['checkoutRuntimeGitBranch'] = this.gitCommands.checkoutRuntimeGitBranch.bind(this.gitCommands) listRuntimeGitLocalBranches: RuntimeGitCommands['listRuntimeGitLocalBranches'] = this.gitCommands.listRuntimeGitLocalBranches.bind(this.gitCommands) getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) getRuntimeGitCommitCompare: RuntimeGitCommands['getRuntimeGitCommitCompare'] = this.gitCommands.getRuntimeGitCommitCompare.bind(this.gitCommands) getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( this.gitCommands ) syncRuntimeGitForkDefaultBranch: RuntimeGitCommands['syncRuntimeGitForkDefaultBranch'] = this.gitCommands.syncRuntimeGitForkDefaultBranch.bind(this.gitCommands) pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.bind( this.gitCommands ) fastForwardRuntimeGit: RuntimeGitCommands['fastForwardRuntimeGit'] = this.gitCommands.fastForwardRuntimeGit.bind(this.gitCommands) rebaseRuntimeGitFromBase: RuntimeGitCommands['rebaseRuntimeGitFromBase'] = this.gitCommands.rebaseRuntimeGitFromBase.bind(this.gitCommands) pushRuntimeGit: RuntimeGitCommands['pushRuntimeGit'] = this.gitCommands.pushRuntimeGit.bind( this.gitCommands ) getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) getRuntimeGitCommitDiff: RuntimeGitCommands['getRuntimeGitCommitDiff'] = this.gitCommands.getRuntimeGitCommitDiff.bind(this.gitCommands) commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( this.gitCommands ) generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] = this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands) discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] = this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands) cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] = this.gitCommands.cancelRuntimeGenerateCommitMessage.bind(this.gitCommands) generateRuntimePullRequestFields: RuntimeGitCommands['generateRuntimePullRequestFields'] = this.gitCommands.generateRuntimePullRequestFields.bind(this.gitCommands) cancelRuntimeGeneratePullRequestFields: RuntimeGitCommands['cancelRuntimeGeneratePullRequestFields'] = this.gitCommands.cancelRuntimeGeneratePullRequestFields.bind(this.gitCommands) stageRuntimeGitPath: RuntimeGitCommands['stageRuntimeGitPath'] = this.gitCommands.stageRuntimeGitPath.bind(this.gitCommands) unstageRuntimeGitPath: RuntimeGitCommands['unstageRuntimeGitPath'] = this.gitCommands.unstageRuntimeGitPath.bind(this.gitCommands) bulkStageRuntimeGitPaths: RuntimeGitCommands['bulkStageRuntimeGitPaths'] = this.gitCommands.bulkStageRuntimeGitPaths.bind(this.gitCommands) bulkUnstageRuntimeGitPaths: RuntimeGitCommands['bulkUnstageRuntimeGitPaths'] = this.gitCommands.bulkUnstageRuntimeGitPaths.bind(this.gitCommands) bulkDiscardRuntimeGitPaths: RuntimeGitCommands['bulkDiscardRuntimeGitPaths'] = this.gitCommands.bulkDiscardRuntimeGitPaths.bind(this.gitCommands) discardRuntimeGitPath: RuntimeGitCommands['discardRuntimeGitPath'] = this.gitCommands.discardRuntimeGitPath.bind(this.gitCommands) getRuntimeGitRemoteFileUrl: RuntimeGitCommands['getRuntimeGitRemoteFileUrl'] = this.gitCommands.getRuntimeGitRemoteFileUrl.bind(this.gitCommands) getRuntimeGitRemoteCommitUrl: RuntimeGitCommands['getRuntimeGitRemoteCommitUrl'] = this.gitCommands.getRuntimeGitRemoteCommitUrl.bind(this.gitCommands) private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{ worktree: ResolvedWorktree repo?: Repo connectionId?: string localGitOptions?: { wslDistro?: string } }> { const store = this.requireStore() const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = store.getRepo(worktree.repoId) const connectionId = repo?.connectionId ?? undefined const localGitOptions = repo && !connectionId ? getLocalProjectWorktreeGitOptions(store, repo) : {} return { worktree, repo, connectionId, localGitOptions } } private async resolveRuntimeFileTarget(worktreeSelector: string): Promise<{ worktree: ResolvedWorktree connectionId?: string }> { const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector) if (folderScope?.folderWorkspace) { return { worktree: this.folderWorkspaceToResolvedWorktree(folderScope.folderWorkspace), connectionId: folderScope.connectionId ?? undefined } } const store = this.requireStore() const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = store.getRepo(worktree.repoId) return { worktree, connectionId: repo?.connectionId ?? undefined } } private async resolveKnownWorkspaceFileTarget( absolutePath: string, executionHostId: ExecutionHostId ): Promise<{ worktree: ResolvedWorktree connectionId?: string relativePath: string } | null> { const targets = new Map< string, { worktree: ResolvedWorktree connectionId?: string executionHostId: ExecutionHostId } >() for (const worktree of await this.listResolvedWorktrees()) { if (!this.isRuntimeWorktreeVisible(worktree)) { continue } const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined const target = { worktree, executionHostId: getRuntimeFileTargetExecutionHostId({ worktree, connectionId: candidateConnectionId }), ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) } targets.set(`${target.executionHostId}\0${worktree.id}`, target) } for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { try { const candidateConnectionId = this.resolveFolderWorkspaceConnectionId(folderWorkspace) ?? undefined const worktree = this.folderWorkspaceToResolvedWorktree(folderWorkspace) const target = { worktree, executionHostId: getRuntimeFileTargetExecutionHostId({ worktree, connectionId: candidateConnectionId }), ...(candidateConnectionId ? { connectionId: candidateConnectionId } : {}) } targets.set(`${target.executionHostId}\0${worktree.id}`, target) } catch { // An ambiguous folder workspace has no single filesystem authority. } } const owner = findRuntimeWorkspaceFileOwner( [...targets.values()].map((target) => ({ workspaceId: target.worktree.id, rootPath: target.worktree.path, executionHostId: target.executionHostId })), absolutePath, executionHostId ) if (!owner) { return null } const target = targets.get(`${owner.executionHostId}\0${owner.workspaceId}`) return target ? { ...target, relativePath: owner.relativePath } : null } onMobileSessionTabsChanged( listener: (snapshot: RuntimeMobileSessionTabsResult) => void, clientNavigationId?: string ): () => void { // Why: a notify coalesced before this subscriber existed is already folded // into the initial snapshot it was just sent. Draining it here — before the // listener joins — keeps that pending timer from landing as a redundant // `updated` frame carrying pre-subscribe state. Mirrors the unsubscribe flush. this.mobileSessionTabsNotifyCoalescer.flushAll() const subscription = { listener, clientNavigationId } this.mobileSessionTabListeners.add(subscription) return () => { // Why: flush pending coalesced notifies before dropping this listener so a // subscriber closing mid-window still receives the latest settled state. this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(subscription) } } forgetClientNavigationState(clientNavigationId: string): void { this.clientSessionTabSelections.forgetClient(clientNavigationId) } // Why: terminal handles are normally created lazily when first referenced via // RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE // env var) so they can self-identify in orchestration messages without an // extra RPC round-trip. Pre-allocating by ptyId lets issueHandle reuse it. preAllocateHandleForPty(ptyId: string): string { const existing = this.handleByPtyId.get(ptyId) if (existing) { return existing } const handle = this.createPreAllocatedTerminalHandle() this.handleByPtyId.set(ptyId, handle) return handle } createPreAllocatedTerminalHandle(): string { return `term_${randomUUID()}` } registerPreAllocatedHandleForPty(ptyId: string, handle: string): void { this.handleByPtyId.set(ptyId, handle) for (const leaf of this.getLeavesForPty(ptyId)) { this.adoptPreAllocatedHandle(leaf) } } private adoptControllerTerminalHandle( ptyId: string, handle: string | undefined, incarnationId?: string, options: { exactRestoredSurface?: boolean } = {} ): void { const trimmed = handle?.trim() if (!trimmed || !trimmed.startsWith('term_')) { return } const pty = this.ptysById.get(ptyId) const changedIncarnation = Boolean( incarnationId && pty?.incarnationId && incarnationId !== pty.incarnationId ) if (changedIncarnation) { const priorHandle = this.handleByPtyId.get(ptyId) this.invalidateAllHandlesForPty(ptyId) pty!.tabId = null pty!.paneKey = null // Reusing an exported handle would make stale client metadata name the replacement process. if (priorHandle === trimmed) { return } } if (this.isTerminalHandleAdoptionBlocked(ptyId, trimmed)) { if ( !options.exactRestoredSurface || !this.replaceSyntheticTerminalHandlesForRestoredPty(ptyId, trimmed) || this.isTerminalHandleAdoptionBlocked(ptyId, trimmed) ) { return } } // Why: after an app/runtime restart, the live PTY child still has its // original ORCA_TERMINAL_HANDLE, but the runtime's in-memory map is gone. this.registerPreAllocatedHandleForPty(ptyId, trimmed) } private invalidateAllHandlesForPty(ptyId: string): void { this.handleByPtyId.delete(ptyId) const invalidated = new Set() for (const [handle, record] of this.handles) { if (record.ptyId === ptyId) { invalidated.add(handle) this.handles.delete(handle) this.syntheticTerminalHandles.delete(handle) this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } } for (const [leafKey, handle] of this.handleByLeafKey) { if (invalidated.has(handle)) { this.handleByLeafKey.delete(leafKey) } } } private replaceSyntheticTerminalHandlesForRestoredPty( ptyId: string, controllerHandle: string ): boolean { const boundHandles = new Set() const directHandle = this.handleByPtyId.get(ptyId) if (directHandle) { boundHandles.add(directHandle) } for (const [handle, record] of this.handles) { if (record.ptyId === ptyId) { boundHandles.add(handle) } else if (handle === controllerHandle) { return false } } for (const [otherPtyId, handle] of this.handleByPtyId) { if (otherPtyId !== ptyId && handle === controllerHandle) { return false } } for (const leaf of this.getLeavesForPty(ptyId)) { const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (handle) { boundHandles.add(handle) } } if ( boundHandles.size === 0 || [...boundHandles].some( (handle) => handle === controllerHandle || !this.syntheticTerminalHandles.has(handle) ) ) { return false } this.invalidateAllHandlesForPty(ptyId) return true } // Why: adoption is best-effort restart recovery and must be first-wins. // Re-keying a pty that already has a handle this session would strand // waiters registered under the old handle, and provider-reported values // are not trusted to be collision-free — a handle bound to a different // pty must never be stolen by a later report. private isTerminalHandleAdoptionBlocked(ptyId: string, handle: string): boolean { if (this.handleByPtyId.get(ptyId) ?? this.findHandleForPtyRecord(ptyId)) { return true } for (const leaf of this.getLeavesForPty(ptyId)) { const issued = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (issued && issued !== handle) { return true } } const existingRecord = this.handles.get(handle) if (existingRecord && existingRecord.ptyId !== ptyId) { return true } for (const [otherPtyId, otherHandle] of this.handleByPtyId) { if (otherHandle === handle && otherPtyId !== ptyId) { return true } } return false } onPtySpawned( ptyId: string, incarnationId?: PtyIncarnationId, options: { awaitsRegistration?: boolean } = {} ): void { if (options.awaitsRegistration !== false) { // Why: surface absence cannot distinguish an in-flight admission from a completed headless lifecycle. this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) } this.spawnPublishedPtys.add(ptyId) const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { if (incarnationId) { pty.incarnationId = incarnationId } pty.connected = true pty.disconnectedAt = null } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.connected = true leaf.writable = this.graphStatus === 'ready' this.adoptPreAllocatedHandle(leaf) } } registerPty( ptyId: string, worktreeId: string, connectionId: string | null = null, binding?: { tabId: string; leafId: string; incarnationId?: PtyIncarnationId }, isWsl?: boolean ): void { this.assertPtyDidNotExitBeforeRegistration(ptyId, binding?.incarnationId) this.spawnPublishedPtys.add(ptyId) // Why: record the renderer pane identity at spawn time so a stalled graph // sync can't hide that a live PTY already backs a pending mobile create. const paneKey = binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId) ? makePaneKey(binding.tabId, binding.leafId) : null this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId, ...(binding && this.pendingMobileTerminalCreatesByKey.has(`${worktreeId}::${binding.tabId}`) ? { runtimeSessionOwned: true } : {}), ...(isWsl !== undefined ? { isWsl } : {}), ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) }) const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) if ( pendingIncarnation === null || pendingIncarnation === undefined || binding?.incarnationId === undefined || pendingIncarnation === binding.incarnationId ) { this.pendingPtyRegistrationIncarnations.delete(ptyId) } // Why: the renderer's own PTY spawn is the reliable signal that the pending // mobile create's tab is live; publish its surface main-side (#7587). if (binding && paneKey) { this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) } } assertPtyRegistrationAllowed(ptyId: string, incarnationId?: PtyIncarnationId): void { // Why: the controller must reject an early exit before persisting bindings or handles. this.assertPtyDidNotExitBeforeRegistration(ptyId, incarnationId) } releaseRejectedPtyRegistrationFence( ptyId: string, candidateIncarnation?: PtyIncarnationId ): void { if (!this.earlyExitedPtyIncarnations.has(ptyId)) { return } const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null if ( exitedIncarnation === null || candidateIncarnation === undefined || exitedIncarnation === candidateIncarnation ) { // Why: the rejected spawn call was the fence's sole late publisher; retaining it leaks fresh PTY ids. this.earlyExitedPtyIncarnations.delete(ptyId) this.pendingPtyRegistrationIncarnations.delete(ptyId) } } beginPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { this.pendingPtyRegistrationIncarnations.set(ptyId, incarnationId ?? null) } acceptPtyIncarnationForExit(ptyId: string, incarnationId: PtyIncarnationId): void { const pty = this.ptysById.get(ptyId) if (pty) { // Why: a reconnect attach reply can prove the exit generation after stale local proof was cleared. pty.incarnationId = incarnationId } } cancelPendingPtyRegistration(ptyId: string, incarnationId?: PtyIncarnationId): void { const pending = this.pendingPtyRegistrationIncarnations.get(ptyId) if ( !this.pendingPtyRegistrationIncarnations.has(ptyId) || (pending !== null && incarnationId !== undefined && pending !== incarnationId) ) { return } this.pendingPtyRegistrationIncarnations.delete(ptyId) const exited = this.earlyExitedPtyIncarnations.get(ptyId) if ( exited === null || exited === undefined || incarnationId === undefined || exited === incarnationId ) { this.earlyExitedPtyIncarnations.delete(ptyId) } } private assertPtyDidNotExitBeforeRegistration( ptyId: string, candidateIncarnation?: PtyIncarnationId ): void { if (this.earlyExitedPtyIncarnations.has(ptyId)) { const exitedIncarnation = this.earlyExitedPtyIncarnations.get(ptyId) ?? null const nextIncarnation = candidateIncarnation ?? null if ( exitedIncarnation === null || nextIncarnation === null || exitedIncarnation === nextIncarnation ) { throw new Error('agent_session_exited_during_start') } this.earlyExitedPtyIncarnations.delete(ptyId) } } preparePtyExecutionContext( ptyId: string, wslDistro: string | null, options: { resetIncarnation?: boolean; preserveExisting?: boolean } = {} ): boolean { const pty = this.ptysById.get(ptyId) const hadExistingContext = this.wslDistroByPtyId.has(ptyId) || pty !== undefined if (options.preserveExisting && hadExistingContext) { // Why: attach-time settings are only a fallback; a live PTY's recorded // execution namespace remains authoritative until its provider replies. return false } if (options.resetIncarnation) { // Why: an explicit new lifecycle supersedes an unidentifiable exit from the reused PTY id. this.earlyExitedPtyIncarnations.delete(ptyId) this.disposeHeadlessTerminal(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) } const previous = this.wslDistroByPtyId.get(ptyId) ?? null if (wslDistro) { this.wslDistroByPtyId.set(ptyId, wslDistro) } else { this.wslDistroByPtyId.delete(ptyId) } if (pty) { pty.wslDistro = wslDistro } if (!options.resetIncarnation && previous !== wslDistro && this.headlessTerminals.has(ptyId)) { // Why: bytes parsed with two distro namespaces would leave an internally // inconsistent CWD; rebuild from the provider's authoritative snapshot. this.terminalCwdByPtyId.delete(ptyId) this.replaceHeadlessTerminalAfterExecutionContextChange(ptyId) } return options.resetIncarnation === true || !hadExistingContext || previous !== wslDistro } /** Record the spawn launch command so the per-PTY Command Code detector can * arm from it (renderer startupCommand parity). Best-effort: a chunk that * beats this call falls back to the detector's banner arming. */ noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { const trimmed = typeof command === 'string' ? command.trim() : '' if (trimmed.length > 0) { this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) } } resetPtyModelAfterMigrationFailure(ptyId: string): void { this.providerSnapshotPreferredPtys.add(ptyId) this.disposeHeadlessTerminal(ptyId) } /** * Handles incoming data from a PTY process, running agent detection, * updating terminal tail buffers, and triggering foreground agent refreshes. */ acceptPtyDataBounded( ptyId: string, data: string, at: number, sequenceChars = data.length, transformed = false, sourceRanges?: readonly TerminalOutputSourceRange[] ): RuntimePtyDataAdmission { let completion: Promise | null = null const sequence = this.onPtyData( ptyId, data, at, sequenceChars, transformed, (receipt) => { completion = receipt }, sourceRanges ) if (!completion) { throw new Error('PTY model admission receipt was not captured') } return Object.freeze({ sequence, completion }) } onPtyData( ptyId: string, data: string, at: number, sequenceChars = data.length, transformed = false, captureModelReceipt?: (completion: Promise) => void, sourceRanges?: readonly TerminalOutputSourceRange[] ): number { const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) this.providerModeTrackersByPtyId.get(ptyId)?.scan(data) for (const tracker of this.providerModeSnapshotScansByPtyId.get(ptyId) ?? []) { tracker.scan(data) } const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd const cwdChanged = osc7Metadata.cwdChanged const agentStatusChunk = this.processAgentStatusOscForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) // Agent detection runs on raw data before leaf processing, since the // tail buffer logic normalizes away the OSC sequences we need. this.agentDetector?.onData(ptyId, data, at) // Why: watch terminal output for advertised dev-server URLs (e.g. Vite's // `Network: https://local.example.com:3001/`) so the workspace ports // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) // Why: reply ownership is captured per chunk, here at ingestion — the // same module state and tick as the hidden-gate drop sites — and rides // the writeChain link. A mark/setting/subscriber flip before the queued // emulator write runs must not change who answers (terminal-query- // authority.md invariant 1). const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer // MUST run before trackHeadlessTerminalData so the eager-state pattern // (set headlessTerminals + writeChain head = seedPromise) is in place // before the live byte's chain link is queued. Without this ordering, // trackHeadlessTerminalData would lazy-create a fresh state at PTY dims // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) // Our structure wins: OSC title/agent-status extraction runs through the // shared per-PTY title tracker below (getOrCreatePtyTitleTrackerEntry → // applyTrackedPtyTitle) in byte order, superseding main's inline // extractLastOscTitleForPty block (#7880/#7852 title/status semantics are // preserved via the tracker + detectAgentStatusFromTitle path). const modelCompletion = this.trackHeadlessTerminalData( ptyId, data, outputSequence, forwardQueryReplies ) captureModelReceipt?.(modelCompletion) const pty = this.getOrCreatePtyWorktreeRecord(ptyId) const ptyTailBefore = pty ? { lines: pty.tailBuffer, transcriptLines: pty.tailTranscriptBuffer, partialLine: pty.tailPartialLine, pendingAnsi: pty.tailPendingAnsi, redrawCursor: pty.tailRedrawCursor, truncated: pty.tailTruncated, linesTotal: pty.tailLinesTotal } : null let ptyTailAfter: ReturnType | null = null if (pty) { pty.connected = true pty.disconnectedAt = null pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) pty.tailPendingAnsi = normalized.pendingAnsi const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, normalized.text, pty.tailRedrawCursor ) ptyTailAfter = nextTail const nextTranscript = appendCompletedTerminalTranscript( pty.tailTranscriptBuffer, pty.tailTranscriptChars, nextTail.newlyCompletedLines, nextTail.newCompleteLines ) pty.tailBuffer = nextTail.lines pty.tailTranscriptBuffer = nextTranscript.lines pty.tailTranscriptChars = nextTranscript.characters pty.tailPartialLine = nextTail.partialLine pty.tailRedrawCursor = nextTail.redrawCursor pty.tailTruncated = pty.tailTruncated || nextTail.truncated || nextTranscript.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) } for (const leaf of this.getLeavesForPty(ptyId)) { this.recordPtyWorktree(ptyId, leaf.worktreeId, { connected: true, lastOutputAt: pty?.lastOutputAt ?? at, preview: pty?.preview ?? leaf.preview, tabId: leaf.tabId, paneKey: this.makeRuntimePaneKey(leaf) }) leaf.connected = true leaf.writable = this.graphStatus === 'ready' leaf.lastOutputAt = at if ( pty && ptyTailBefore && ptyTailAfter && tailStateMatches( leaf.tailBuffer, leaf.tailTranscriptBuffer, leaf.tailPartialLine, leaf.tailPendingAnsi, leaf.tailRedrawCursor, leaf.tailTruncated, leaf.tailLinesTotal, ptyTailBefore ) ) { // Why: the leaf and PTY record usually mirror the same terminal. Reuse // the PTY tail update instead of splitting large output twice. leaf.tailBuffer = pty.tailBuffer leaf.tailTranscriptBuffer = pty.tailTranscriptBuffer leaf.tailTranscriptChars = pty.tailTranscriptChars leaf.tailPartialLine = pty.tailPartialLine leaf.tailPendingAnsi = pty.tailPendingAnsi leaf.tailRedrawCursor = pty.tailRedrawCursor leaf.tailTruncated = pty.tailTruncated leaf.tailLinesTotal = pty.tailLinesTotal leaf.preview = pty.preview leaf.waitBlockedAt = pty.waitBlockedAt // Why undefined on this branch: the PTY record's wait scan is throttled // (scheduleWaitBlockedCheck), so pty.tailWaitState is never populated; // copying it here intentionally invalidates the leaf cache and the // mismatch branch below recomputes an exact state on its next chunk. leaf.tailWaitState = pty.tailWaitState } else { const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) leaf.tailPendingAnsi = normalized.pendingAnsi const previousWaitState = leaf.tailWaitState?.fromTail === true ? leaf.tailWaitState : computeTerminalTailWaitState(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) const nextTail = appendNormalizedToTailBuffer( leaf.tailBuffer, leaf.tailPartialLine, normalized.text, leaf.tailRedrawCursor ) const nextTranscript = appendCompletedTerminalTranscript( leaf.tailTranscriptBuffer, leaf.tailTranscriptChars, nextTail.newlyCompletedLines, nextTail.newCompleteLines ) const nextWaitState = computeTerminalTailWaitState( nextTail.lines, nextTail.partialLine, leaf.preview ) if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) { leaf.waitBlockedAt = at } leaf.tailWaitState = nextWaitState leaf.tailBuffer = nextTail.lines leaf.tailTranscriptBuffer = nextTranscript.lines leaf.tailTranscriptChars = nextTranscript.characters leaf.tailPartialLine = nextTail.partialLine leaf.tailRedrawCursor = nextTail.redrawCursor leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated || nextTranscript.truncated leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) } } // Why: feed the chunk's OSC titles through the shared per-PTY tracker in // byte order — the same ordering the renderer transport uses — so // coalesced working→idle transitions reach tui-idle waiters and // pending-message delivery instead of being masked by the chunk's last // title (issue #1083). Uses the OSC 9999-stripped cleanData like the // renderer, so pure status chunks don't perturb the stale-title probe. const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) const titleInput = previousTitleScanTail ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` : agentStatusChunk.cleanData const nextTitleScanTail = extractOscTitleScanTail(titleInput) if (nextTitleScanTail.length > 0) { this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail) } else { this.oscTitleScanTailByPtyId.delete(ptyId) } titleTrackerEntry.applyingChunk = true titleTrackerEntry.chunkTouchedSessionTabs = false let retainedAgentStatusChanged = false try { for (const payload of agentStatusChunk.payloads) { titleTrackerEntry.pendingFacts.push({ kind: 'agent-status', payload }) } titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { titleScanData: titleInput }) // Why: the Command Code scrape rides the same per-chunk batch (its facts // trail the tracker's). cleanData keeps OSC 9999 payloads out of the // detector's bounded recent-text window; the detector strips remaining // control sequences itself, exactly like the renderer byte path. titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) } finally { titleTrackerEntry.applyingChunk = false try { // Why: per-chunk cross-channel contract order is status → titles → // bell — the chunk's agentStatus:set events must reach the renderer // before its pty:sideEffect batch. retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) } finally { // Why: flushed in the finally so a throwing tracker callback cannot // strand this chunk's facts to be emitted under the next chunk's seq. this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) } } // Why: hook (OSC 9999) transitions often arrive without a title change, so // headless-serve snapshots would never republish and paired remote clients // kept the stale agent state until the next title change (#7970). if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } const listeners = this.dataListeners.get(ptyId) if (listeners) { const meta = { seq: outputSequence, rawLength: sequenceChars, ...(transformed ? { transformed: true } : {}), ...(cwdChanged && cwd !== null ? { cwd } : {}), ...(sourceRanges && sourceRanges.length > 0 ? { sourceRanges } : {}) } for (const listener of listeners) { try { listener(data, meta) } catch (error) { // Why: inlined rather than via notifyRuntimeListeners to avoid a per-chunk closure // allocation on the terminal-output hot path; isolation semantics match the helper. console.error('[runtime] pty-data listener threw', error) } } } return outputSequence } private scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void { let state = this.waitBlockedCheckStateByPtyId.get(ptyId) if (!state) { state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } this.waitBlockedCheckStateByPtyId.set(ptyId, state) } const appendedLower = appendedText.toLowerCase() const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS) // Why the cap keeps the tail: the accumulated text only anchors boundary- // spanning prompt detection; anything past the tail cap has scrolled out // of the retained tail the check reads anyway. state.appended = state.appended.length + appendedText.length > MAX_TAIL_CHARS ? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS) : `${state.appended}${appendedText}` const elapsed = at - state.lastAt if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) { this.runWaitBlockedCheck(ptyId, state, at) return } if (!state.timer) { // Why trailing edge: the final chunks of a burst must still be // evaluated or a prompt arriving right after a flood would go // unstamped until the next output. state.timer = setTimeout(() => { state.timer = null this.runWaitBlockedCheck(ptyId, state, Date.now()) }, WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS - elapsed) } } private runWaitBlockedCheck( ptyId: string, state: { lastAt: number lastWaitState: TerminalTailWaitState | null appended: string keywordCarry: string timer: ReturnType | null }, at: number ): void { const pty = this.ptysById.get(ptyId) if (!pty) { state.appended = '' return } const nextWaitState = computeTerminalTailWaitState( pty.tailBuffer, pty.tailPartialLine, pty.preview ) const previousWaitState = state.lastWaitState ?? { waitText: '', signal: null, fromTail: false } if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { pty.waitBlockedAt = at } state.lastAt = at state.lastWaitState = nextWaitState state.appended = '' } // Why: the scanner's first run after a restore seed compares against a null // baseline, so a permission prompt visible only in seeded HISTORY would read // as newly gained and stamp waitBlockedAt "now" on the next benign chunk. // Store the seeded tail's wait state as the baseline WITHOUT stamping; only // a signal that appears in genuinely new output counts as gained. private primeWaitBlockedBaselineFromSeededTail(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (!pty) { return } let state = this.waitBlockedCheckStateByPtyId.get(ptyId) if (!state) { state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } this.waitBlockedCheckStateByPtyId.set(ptyId, state) } if (state.lastWaitState === null) { state.lastWaitState = computeTerminalTailWaitState( pty.tailBuffer, pty.tailPartialLine, pty.preview ) } } private clearWaitBlockedCheckState(ptyId: string): void { const state = this.waitBlockedCheckStateByPtyId.get(ptyId) if (state?.timer) { clearTimeout(state.timer) } this.waitBlockedCheckStateByPtyId.delete(ptyId) } private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) if (!processor) { processor = createAgentStatusOscProcessor() this.agentStatusOscProcessorsByPtyId.set(ptyId, processor) } return processor(data) } /** Emit the facts batched while applying one chunk/frame as a single * pty:sideEffect batch, preserving byte order. */ private flushPendingTerminalSideEffectFacts( ptyId: string, entry: RuntimePtyTitleTrackerEntry ): void { if (entry.pendingFacts.length === 0) { return } const facts = entry.pendingFacts entry.pendingFacts = [] this.emitTerminalSideEffectBatch(ptyId, facts) } /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through * the per-PTY tracker — NOT onPtyData, so emulator state, tails, * transcripts, and stats never see synthetic bytes. Parsed via the * tracker's stateless synthetic path: the shared chunk bell detector must * never observe fabricated bytes, or a tick interleaved with a split real * OSC corrupts its escape state (phantom/swallowed bells). While the * side-effect kill switch is off the legacy pty:data copy still drives * renderer parsers; this ingest keeps main's facts and records * authoritative. */ ingestSyntheticTitleFrame(ptyId: string, data: string): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.applyingChunk = true entry.applyingSyntheticFrame = true entry.chunkTouchedSessionTabs = false try { entry.tracker.applySyntheticTitleFrame(data) } finally { entry.applyingChunk = false entry.applyingSyntheticFrame = false this.flushPendingTerminalSideEffectFacts(ptyId, entry) } if (entry.chunkTouchedSessionTabs) { this.touchMobileSessionSnapshotsForPty(ptyId) } } /** Scan-authority handoff for a backgrounded PTY (daemon keep-tail * thinning): while delegated, the daemon relays bell/133/pr-link/2031 * facts itself and the delivered bytes may be gapped — feeding them to * main's transient scanners would mint phantom or duplicate facts. Title * processing stays main-side either way. */ setPtyTransientFactDelegation( ptyId: string, delegated: boolean, scanSeedAnsi?: string, mode2031PendingSubscribe?: true ): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.tracker.setTransientFactScanningSuppressed(delegated) if (!delegated && scanSeedAnsi) { // Prime the freshly reset scanner carry with the emulator's dangling // incomplete escape at the handoff position — a sequence split across // the un-background toggle must not mint a phantom bell or lose its // fact. titleScanData:'' keeps titles out (they were never suppressed). entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '', mode2031PendingSubscribe }) } } /** A transient fact the daemon detected while it held scan authority — * emitted through the same fact channel as byte-scanned facts. Arrives * between chunks, so recordTerminalSideEffectFact emits it immediately. */ emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { switch (fact.kind) { case 'bell': this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode: fact.exitCode }) return case 'pr-link': this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) return case '2031-subscribe': this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) return case '2031-unsubscribe': this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) } } /** The daemon keep-tail dropped this PTY's oldest undelivered output; the * next delivered chunk is discontinuous. Reset every cross-chunk parse * carry so a half-open escape from before the gap cannot corrupt what * follows, and drop the mobile headless mirror — it rebuilds from the * delivered tail / snapshot seeds instead of parsing a gapped stream. */ notePtyDataGap(ptyId: string, droppedChars = 0): void { if (droppedChars > 0) { // Why: the daemon snapshot's seq counts bytes its monitoring stream // dropped. Advancing without parsing preserves that absolute domain so // post-snapshot live chunks can be reconciled instead of duplicated. const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars this.ptyOutputSequenceById.set(ptyId, outputSequence) } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (pty) { pty.tailPendingAnsi = '' } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.tailPendingAnsi = '' } this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.disposeHeadlessTerminal(ptyId) } /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { if (!this.terminalSideEffectConsumerAvailable) { return } const entry = this.ptyTitleTrackersByPtyId.get(ptyId) if (entry?.applyingChunk) { entry.pendingFacts.push(fact) return } this.emitTerminalSideEffectBatch(ptyId, [fact]) } private emitTerminalSideEffectBatch( ptyId: string, facts: TerminalSideEffectFact[], options: { replay?: boolean } = {} ): void { if (!this.terminalSideEffectConsumerAvailable || facts.length === 0) { return } const batch: TerminalSideEffectBatch = { ptyId, seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, facts, ...(options.replay ? { replay: true } : {}), ...this.resolveTerminalSideEffectAttribution(ptyId) } if (this.terminalSideEffectLocalConsumerAvailable) { try { this.onTerminalSideEffects?.(batch) } catch (err) { console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) } } if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) { this.emitClientEvent({ type: 'terminalSideEffects', batch }) } } /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the * first mounted leaf, fall back to the spawn-time PTY record binding. */ private resolveTerminalSideEffectAttribution(ptyId: string): { worktreeId?: string tabId?: string paneKey?: string connectionId?: string | null } { const pty = this.ptysById.get(ptyId) const connectionId = pty?.connectionId ?? null for (const leaf of this.getLeavesForPty(ptyId)) { return { worktreeId: leaf.worktreeId, tabId: leaf.tabId, paneKey: this.makeRuntimePaneKey(leaf), connectionId } } if (pty?.paneKey) { return { worktreeId: pty.worktreeId, ...(pty.tabId ? { tabId: pty.tabId } : {}), paneKey: pty.paneKey, connectionId } } return {} } /** Title-only replay batch for renderer (re)attach — the no-attention-replay * rule: snapshots restore title state, never historical bells/completions. */ getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle // Why: the cursor-agent literal drop applies to every title surface; a // record-fallback snapshot must not replay the bare native title the // tracker would have refused to emit live. const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null if (normalizedTitle === null && !rawTitle) { return null } return { ptyId, seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, replay: true, facts: [ { kind: 'title', normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), rawTitle: rawTitle ?? normalizedTitle! } ], ...this.resolveTerminalSideEffectAttribution(ptyId) } } /** Raw last title from main's tracked PTY/leaf records — the title surface * the tracker (live bytes + synthetic frames) keeps current. */ private getTrackedRawTitleForPty(ptyId: string): string | null { const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle if (recordTitle) { return recordTitle } for (const leaf of this.getLeavesForPty(ptyId)) { if (leaf.lastOscTitle) { return leaf.lastOscTitle } } return null } /** Why: synthetic agent title frames no longer ride pty:data, so neither * renderer xterm nor the headless emulator observes them. Mobile-parity * snapshot titles must prefer main's tracker over snapshot lastTitle, or * hook-driven spinner/idle titles vanish from mobile tabs. */ private preferTrackedLastTitle(ptyId: string, snapshot: T): T { const tracked = this.getTrackedRawTitleForPty(ptyId) if (!tracked) { return snapshot } return { ...snapshot, lastTitle: tracked } } /** Decorative comparison key: spinner frame glyphs stripped, derived agent * status kept so a working→idle flip with an otherwise-equal label still * counts as a change. */ private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( normalizedTitle )}` } private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { const existing = this.ptyTitleTrackersByPtyId.get(ptyId) if (existing) { return existing } // Why: trackers are created lazily on the first observed chunk. After an // app relaunch the PTY/leaf records can already hold a persisted title; a // cold tracker would miss the parked working→idle completion and never // arm the stale-title timer for a persisted 'working' title. let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null if (initialTitle === null) { for (const leaf of this.getLeavesForPty(ptyId)) { if (leaf.lastOscTitle) { initialTitle = leaf.lastOscTitle break } } } const tracker = createTerminalTitleTracker( { onTitle: (normalizedTitle, rawTitle, meta) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'title', normalizedTitle, rawTitle, ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle) if (!changed) { return } const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } if (live?.applyingChunk) { // Why: synthetic spinner ticks change only the braille glyph // ~12.5x/sec; fanning out full mobile session snapshots per frame // is pure churn. Raw lastOscTitle updates above stay cheap. if (!(live.applyingSyntheticFrame && decorativeOnly)) { live.chunkTouchedSessionTabs = true } } else { // Stale-working-title timer path — fires between chunks, so the // per-chunk batching in onPtyData cannot pick it up. this.touchMobileSessionSnapshotsForPty(ptyId) } }, // Why: agent transitions and bells become pty:sideEffect facts — // main is the single byte parser for local/SSH PTYs; the renderer // store handler decides what the facts mean (notification policy). onAgentBecameWorking: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) }, onAgentBecameIdle: (title, meta) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-idle', title, ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) }, onAgentExited: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) }, onCommandFinished: (exitCode: number | null) => { this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) }, onPrLink: (link: TerminalGitHubPRLink) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) }, // Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful. onMode2031Subscribe: () => { this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) }, onMode2031Unsubscribe: () => { this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) } }, initialTitle !== null ? { initialTitle } : {} ) tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, applyingSyntheticFrame: false, lastMobileTitleGateKey: null, chunkTouchedSessionTabs: false, pendingFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. commandCodeDetector: this.terminalSideEffectConsumerAvailable ? this.createTerminalSideEffectCommandCodeDetector(ptyId) : null } this.ptyTitleTrackersByPtyId.set(ptyId, entry) return entry } /** Apply one observed OSC title (raw form) to the PTY and leaf records. * Returns true when the PTY record's title or status changed. */ private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean { // Why: status is detected from the RAW title (mirrors the renderer tracker), // so working/idle transitions are unaffected by normalization; the records // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to // one stable stored label (#7880) instead of churning `ps`/mobile tabs. const agentStatus = detectAgentStatusFromTitle(rawTitle) let ptyRecordChanged = false const pty = this.ptysById.get(ptyId) if (pty) { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() pty.lastOscTitle = normalizedTitle pty.lastOscTitleAt = observedAt pty.lastOscTitleEpochMs = Date.now() pty.lastAgentStatus = agentStatus this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolvePtyTuiIdleWaiters(pty, ptyId) } const shouldDelayMobileSnapshot = ptyRecordChanged && this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle) let foregroundRefresh: Promise | undefined // Why: gate on an actual status transition — braille spinner frames // mutate the title every tick, so probing per-title-change would stream // a foreground query per frame during active work. if (prevStatus !== agentStatus) { foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { afterTitleObservation: observedAt }) } else if (shouldDelayMobileSnapshot) { // Why: same-status compatible title changes can arrive before the // foreground owner probe settles; publishing them would flicker. foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) } if (foregroundRefresh && shouldDelayMobileSnapshot) { // Why: report "unchanged" so the per-chunk batch skips the mobile // snapshot fan-out; the delayed publish fires when the probe settles. ptyRecordChanged = false this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) } } for (const leaf of this.getLeavesForPty(ptyId)) { // Why: keep the latest OSC title on the leaf so worktree.ps can // recompute status from the live title each call. Without this, // daemon-hosted terminals (no renderer pushing pane titles) had no // way to clear a stale 'working' status after the agent exited and // the shell took over the title — the stuck-spinner bug in #1437. leaf.lastOscTitle = normalizedTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() const prevStatus = leaf.lastAgentStatus // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so // it is no longer sticky. Tui-idle waiters that needed the previous // 'idle' transition were already resolved at the moment of the // transition below; only fresh waiters registered after the agent // exits would observe the cleared value, and they correctly fall // back to title-based detection / polling. leaf.lastAgentStatus = agentStatus // Why: resolve tui-idle on any transition TO idle (not just working→idle). // Claude Code may skip "working" entirely on fast tasks, going null→idle, // and the coordinator's tui-idle waiter would hang forever waiting for a // working→idle transition that never comes. Permission→idle is excluded: // it means the agent was blocked on user approval and the user said no, // which isn't a task-completion signal. if (agentStatus === 'idle' && prevStatus !== 'idle') { this.resolveTuiIdleWaiters(leaf) this.deliverPendingMessages(leaf) } } return ptyRecordChanged } /** Cancel the per-PTY title tracker (stale-title timer included) on PTY * teardown so it cannot fire into pruned records. */ private disposePtyTitleTracker(ptyId: string): void { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { titleGateKeys.delete(ptyId) } } private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { // Why: a replacement daemon session can reuse the PTY id, but title/parser // state from the prior process must not bleed into its snapshots or chunks. this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) const pty = this.ptysById.get(ptyId) if (pty) { pty.lastOscTitle = null pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null pty.lastAgentStatus = null pty.managementTitle = null pty.managementTitleAt = null } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = null leaf.lastOscTitleAt = null leaf.lastAgentStatus = null } this.clearAgentRowSnapshotsForPty(ptyId) } private setTerminalSideEffectConsumerAvailable(available: boolean): void { this.terminalSideEffectLocalConsumerAvailable = available && this.onTerminalSideEffects !== null this.refreshTerminalSideEffectConsumerAvailability() } private refreshTerminalSideEffectConsumerAvailability(): void { const nextAvailable = this.terminalSideEffectLocalConsumerAvailable || this.countTerminalSideEffectConsumingClientEventListeners() > 0 if (nextAvailable === this.terminalSideEffectConsumerAvailable) { return } this.terminalSideEffectConsumerAvailable = nextAvailable for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) { entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable) entry.commandCodeDetector = nextAvailable ? this.createTerminalSideEffectCommandCodeDetector(ptyId) : null } } private createTerminalSideEffectCommandCodeDetector( ptyId: string ): NonNullable { return createCommandCodeOutputStatusDetector({ startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, onWorking: (prompt) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) }, onDone: (prompt) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) } }) } private extractLastOsc7CwdForPty( ptyId: string, data: string ): { path: string; hostname: string } | null { const previousTail = this.osc7ScanTailByPtyId.get(ptyId) if (!previousTail && !data.includes('\x1b]7;')) { return null } const input = `${previousTail ?? ''}${data}` const scanTail = extractOscScanTail(input, 4096) if (scanTail.length > 0) { this.osc7ScanTailByPtyId.set(ptyId, scanTail) } else { this.osc7ScanTailByPtyId.delete(ptyId) } const uri = extractLastOsc7Uri(input) const pty = this.ptysById.get(ptyId) const pathFlavor = this.pathFlavorForPty(pty) return uri ? parseFileUriPathParts(uri, { pathFlavor, remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32', wslDistro: pty?.connectionId ? undefined : (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined) }) : null } private recordOsc7MetadataForPty( ptyId: string, data: string ): { cwd: string | null; cwdChanged: boolean } { const osc7 = this.extractLastOsc7CwdForPty(ptyId, data) const cwd = osc7?.path ?? null const cwdChanged = cwd !== null && cwd.trim().length > 0 && this.terminalCwdByPtyId.get(ptyId) !== cwd if (cwdChanged) { this.terminalCwdByPtyId.set(ptyId, cwd) } if (osc7) { if (osc7.hostname) { this.terminalFileUriHostnameByPtyId.set(ptyId, osc7.hostname) } else { this.terminalFileUriHostnameByPtyId.delete(ptyId) } } return { cwd, cwdChanged } } private pathFlavorForPty(pty?: RuntimePtyWorktreeRecord | null): 'posix' | 'win32' { if (!pty?.connectionId) { return process.platform === 'win32' ? 'win32' : 'posix' } const worktreePath = splitWorktreeIdForFilesystem(pty.worktreeId)?.worktreePath return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } /** Returns true when any retained agent-row snapshot changed in a * client-visible way, so the caller can republish session snapshots. */ private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean { // Why: snapshot retention (for mobile worktree.ps) must run even when no // renderer listener is attached, so we don't early-return on a missing // onTerminalAgentStatus — only the per-target emit below is gated on it. if (chunk.payloads.length === 0) { return false } const targets = new Map< string, { source: 'mounted-leaf' | 'pty-record' paneKey: string tabId?: string worktreeId?: string connectionId?: string | null } >() const pty = this.ptysById.get(ptyId) const connectionId = pty?.connectionId ?? null for (const leaf of this.getLeavesForPty(ptyId)) { const paneKey = this.makeRuntimePaneKey(leaf) targets.set(paneKey, { source: 'mounted-leaf', paneKey, tabId: leaf.tabId, worktreeId: leaf.worktreeId, connectionId }) } if (targets.size === 0 && pty?.paneKey) { targets.set(pty.paneKey, { source: 'pty-record', paneKey: pty.paneKey, tabId: pty.tabId ?? undefined, worktreeId: pty.worktreeId, connectionId }) } let retainedChanged = false for (const payload of chunk.payloads) { for (const target of targets.values()) { retainedChanged = this.retainAgentRowSnapshot( ptyId, target.paneKey, target.worktreeId, target.tabId, target.connectionId ?? null, payload ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } try { this.onTerminalAgentStatus({ ptyId, ...target, payload }) } catch (err) { console.error('[runtime] terminal agent status listener threw', { ptyId, paneKey: target.paneKey, state: payload.state, agentType: payload.agentType, err }) } } } return retainedChanged } private retainAgentRowSnapshot( ptyId: string, paneKey: string, worktreeId: string | undefined, tabId: string | undefined, connectionId: string | null, payload: ParsedAgentStatusPayload ): boolean { const now = Date.now() const previous = this.latestAgentStatusByPaneKey.get(paneKey) // Why: stateStartedAt must mark the transition into the current state, not // every within-state ping (tool/prompt updates keep the state but refresh // updatedAt) — mirrors AgentStatusEntry.stateStartedAt on the desktop side. const stateStartedAt = previous && previous.payload.state === payload.state ? previous.stateStartedAt : now this.latestAgentStatusByPaneKey.set(paneKey, { paneKey, ptyId, worktreeId, tabId, connectionId, payload, stateStartedAt, updatedAt: now }) // Client-visible change detection: snapshot republish is gated on this so // repeated same-state hook pings don't fan a rebuild out to every client. return ( !previous || previous.payload.state !== payload.state || previous.payload.prompt !== payload.prompt || (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) ) } private clearAgentRowSnapshotsForPty(ptyId: string): void { for (const [paneKey, snapshot] of this.latestAgentStatusByPaneKey) { if (snapshot.ptyId === ptyId) { this.latestAgentStatusByPaneKey.delete(paneKey) } } } getPtyOutputSequence(ptyId: string): number { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } private getPtyLifecycleGeneration(ptyId: string): number { const existing = this.ptyLifecycleGenerationById.get(ptyId) if (existing !== undefined) { return existing } const generation = this.nextPtyLifecycleGeneration++ this.ptyLifecycleGenerationById.set(ptyId, generation) return generation } private advancePtyLifecycleGeneration(ptyId: string): void { this.ptyLifecycleGenerationById.set(ptyId, this.nextPtyLifecycleGeneration++) this.legacyWorkerRecoveredPtys.delete(ptyId) // Why: a respawn under the same session id needs its own subscriber-driven attach. this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) this.spawnPublishedPtys.delete(ptyId) // Why: a provider response belongs to the process generation that issued // it; a respawn must neither reuse its frame nor join its in-flight call. this.providerBufferAcquisitionsByPtyId.delete(ptyId) this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) } synchronizePtyOutputSequenceFromProvider( ptyId: string, providerSequence: { value: number; generation: 'continued' | 'reset' }, runtimeSequenceAtSpawnStart = 0 ): number { if ( !Number.isFinite(providerSequence.value) || providerSequence.value < 0 || !Number.isFinite(runtimeSequenceAtSpawnStart) || runtimeSequenceAtSpawnStart < 0 ) { return this.getPtyOutputSequence(ptyId) } const baseline = Math.floor(providerSequence.value) const currentSequence = this.getPtyOutputSequence(ptyId) const sequenceAtSpawnStart = Math.min(currentSequence, Math.floor(runtimeSequenceAtSpawnStart)) const postSpawnSequence = currentSequence - sequenceAtSpawnStart const wasInitialized = this.providerSequenceInitializedPtys.has(ptyId) const replacesExistingRuntimeGeneration = wasInitialized || sequenceAtSpawnStart > 0 const providerOffset = providerSequence.generation === 'reset' ? sequenceAtSpawnStart : (this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0) const providerBaseline = providerOffset + baseline if (providerSequence.generation === 'reset') { this.advancePtyLifecycleGeneration(ptyId) // Why: daemon respawn/cold restore starts a new absolute domain. Old // emulator state cannot remain authoritative over the replacement. if (replacesExistingRuntimeGeneration) { this.disposeHeadlessTerminal(ptyId) } this.providerModeTrackersByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) const pty = this.ptysById.get(ptyId) if (pty) { pty.wslDistro = null } if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { this.resetTrackedTerminalStateForProviderGeneration(ptyId) } } const synchronizedSequence = providerSequence.generation === 'reset' ? currentSequence : wasInitialized ? currentSequence : providerBaseline + postSpawnSequence this.ptyOutputSequenceById.set(ptyId, synchronizedSequence) this.providerSequenceInitializedPtys.add(ptyId) this.providerSequenceOffsetByPtyId.set(ptyId, providerOffset) const snapshotMayCoverMissingState = (providerSequence.generation === 'continued' && !wasInitialized) || (postSpawnSequence > 0 && providerSequence.generation === 'reset' && replacesExistingRuntimeGeneration) || (providerSequence.generation === 'continued' && wasInitialized && providerBaseline > currentSequence) if (snapshotMayCoverMissingState) { // Why: bytes can cross the control/stream sockets around attach. Until a // full renderer/provider snapshot is available, a partial model is unsafe. this.providerSnapshotPreferredPtys.add(ptyId) } else if (providerSequence.generation === 'reset') { this.providerSnapshotPreferredPtys.delete(ptyId) } const headless = this.headlessTerminals.get(ptyId) if (headless && !wasInitialized && providerSequence.generation === 'continued') { // Why: daemon bytes can reach main just before spawn resolves. Queue the // baseline behind those writes so their emulator sequence is rebased too. headless.writeChain = headless.writeChain.then(() => { headless.outputSequence = synchronizedSequence }) } return synchronizedSequence } subscribeToTerminalData( ptyId: string, listener: (data: string, meta?: RuntimeTerminalDataMeta) => void ): () => void { return addListenerToMap(this.dataListeners, ptyId, listener) } setRemoteTerminalSourceRangeConsumerHooks( hooks: RemoteTerminalSourceRangeConsumerHooks | null ): void { this.remoteTerminalSourceRangeConsumerHooks = hooks } attachRemoteTerminalSourceRangeConsumer( identity: RemoteTerminalSourceRangeStreamIdentity ): boolean { return this.remoteTerminalSourceRangeConsumerHooks?.attach(identity) ?? false } settleRemoteTerminalSourceRanges( identity: RemoteTerminalSourceRangeStreamIdentity, ranges: readonly TerminalOutputSourceRange[] ): void { this.remoteTerminalSourceRangeConsumerHooks?.settle(identity, ranges) } reserveRemoteTerminalSourceRangeReplacement( identity: RemoteTerminalSourceRangeStreamIdentity, requiredSeq: number, reason: string ): RemoteTerminalSourceRangeReplacementReservation | null { return ( this.remoteTerminalSourceRangeConsumerHooks?.reserveReplacement( identity, requiredSeq, reason ) ?? null ) } commitRemoteTerminalSourceRangeReplacement( reservation: RemoteTerminalSourceRangeReplacementReservation, publication: RemoteTerminalSourceRangeReplacementPublication ): boolean { return ( this.remoteTerminalSourceRangeConsumerHooks?.commitReplacement(reservation, publication) ?? false ) } rollbackRemoteTerminalSourceRangeReplacement( reservation: RemoteTerminalSourceRangeReplacementReservation, reason: string ): boolean { return ( this.remoteTerminalSourceRangeConsumerHooks?.rollbackReplacement(reservation, reason) ?? false ) } cancelRemoteTerminalSourceRanges( identity: RemoteTerminalSourceRangeStreamIdentity, ranges: readonly TerminalOutputSourceRange[], reason: string ): void { this.remoteTerminalSourceRangeConsumerHooks?.cancel(identity, ranges, reason) } /** Set by pty IPC: fires when a PTY gains/loses remote view subscribers so * the daemon background mark (keep-tail stream thinning) can resync — a * live mobile/web view consumes raw bytes and must never be thinned, even * while the desktop pane is hidden. */ onRemoteTerminalViewPresenceChanged: ((ptyId: string) => void) | null = null private notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { try { this.onRemoteTerminalViewPresenceChanged?.(ptyId) } catch (err) { console.error('[runtime] remote view presence listener threw', { ptyId, err }) } } /** Registered by terminal-RPC subscribe/multiplex streams: while a remote * view subscriber is attached its xterm answers queries with view * authority and the model responder must stay silent. Returns an * idempotent release. */ registerRemoteTerminalViewSubscriber(ptyId: string): () => void { this.remoteTerminalViewSubscriberCounts.set( ptyId, (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 ) this.ensureSubscriberDrivenProviderAttach(ptyId) this.notifyRemoteTerminalViewPresenceChanged(ptyId) let released = false return () => { if (released) { return } released = true const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 if (next <= 0) { this.remoteTerminalViewSubscriberCounts.delete(ptyId) } else { this.remoteTerminalViewSubscriberCounts.set(ptyId, next) } this.notifyRemoteTerminalViewPresenceChanged(ptyId) } } /** A local daemon session main knows is live but has never ingested a byte * from — i.e. no pane ever attached it, so the daemon is not emitting. * Headless state exists only after the first ingested byte; a snapshot * reconcile in flight implies a spawn-path attach already happened. */ private isKnownUnattachedLocalDaemonPty(ptyId: string): boolean { if (this.headlessTerminals.has(ptyId) || this.providerSnapshotPreferredPtys.has(ptyId)) { return false } // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. if ( this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId) ) { return false } // SSH panes have their own lease/reattach machinery. if (parseAppSshPtyId(ptyId)) { return false } const pty = this.ptysById.get(ptyId) return pty !== undefined && pty.connectionId === null && pty.connected } /** First remote view subscriber of a never-attached local daemon session: * have main attach so output starts flowing. Attach-only (never spawns), * no resize, no renderer mount/focus — works headless. Releases never * detach: continued ingestion is the point, and daemon detach is stubbed. */ private ensureSubscriberDrivenProviderAttach(ptyId: string): void { const controller = this.ptyController if ( !controller?.attach || this.subscriberDrivenProviderAttachesByPtyId.has(ptyId) || !this.isKnownUnattachedLocalDaemonPty(ptyId) ) { return } const attach = controller.attach // Async wrapper: a synchronous controller throw must not break subscribe. const attempt = (async () => attach(ptyId))().catch(() => false) this.subscriberDrivenProviderAttachesByPtyId.set(ptyId, attempt) void attempt.then((attached) => { // Why: an unprovable session must not be pinned as attached; a later // subscriber may retry once the daemon can prove it. if (!attached && this.subscriberDrivenProviderAttachesByPtyId.get(ptyId) === attempt) { this.subscriberDrivenProviderAttachesByPtyId.delete(ptyId) } }) } /** Mark a raw-output viewer without transferring terminal query authority. */ registerRawTerminalViewSubscriber(ptyId: string): () => void { this.rawTerminalViewSubscriberCounts.set( ptyId, (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 ) this.notifyRemoteTerminalViewPresenceChanged(ptyId) let released = false return () => { if (released) { return } released = true const next = (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 if (next <= 0) { this.rawTerminalViewSubscriberCounts.delete(ptyId) } else { this.rawTerminalViewSubscriberCounts.set(ptyId, next) } this.notifyRemoteTerminalViewPresenceChanged(ptyId) } } /** Raw stream presence prevents provider thinning without changing reply ownership. */ hasRawTerminalViewSubscriber(ptyId: string): boolean { return ( (this.rawTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0 || this.hasRemoteTerminalViewSubscriber(ptyId) ) } hasRemoteTerminalViewSubscriber(ptyId: string): boolean { if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { return true } return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 } isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { // Why: a passive phone watching desktop-sized output must not race the // desktop xterm. Mobile becomes reply authority only with the mobile floor. if (this.getDriver(ptyId).kind !== 'mobile') { return false } const subscribers = this.mobileSubscribers.get(ptyId) if (!subscribers) { return false } // Why: soft-leave resubscribe preserves the original subscription time but // reinserts the record. Elect fitted responders from that stable age, not // mutable Map order or passive desktop-mode watchers. let earliest: { clientId: string; subscribedAt: number } | null = null for (const subscriber of subscribers.values()) { if (!subscriber.wasResizedToPhone) { continue } if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) { earliest = subscriber } } return earliest?.clientId === clientId } subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit' cols: number rows: number }) => void ): () => void { return addListenerToMap(this.fitOverrideListeners, ptyId, listener) } subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void { return addListenerToMap(this.driverListeners, ptyId, listener) } private notifyFitOverrideListeners( ptyId: string, mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit', cols: number, rows: number ): void { const listeners = this.fitOverrideListeners.get(ptyId) if (!listeners) { return } notifyRuntimeListeners(listeners, (listener) => listener({ mode, cols, rows }), 'fit-override') } serializeTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } async serializeAuthoritativeTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise { const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts, { timeoutMs: AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS, retireOnTimeout: true }) if (providerSnapshot) { return providerSnapshot } return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } /** Raw keystroke pass-through for the pop-out dashboard's terminal preview. * Honors the mobile-presence lock like the main window's pty:write path. */ async writeTerminalPreviewInput(ptyId: string, data: string): Promise { if (data.length === 0 || this.getDriver(ptyId).kind === 'mobile') { return false } try { await assertTerminalInputWithinLimitWithYield(data) await this.writeTerminalInputChunks(ptyId, data, { // Why: a phone can claim the floor while a paste yields between chunks. beforeWrite: () => { if (this.getDriver(ptyId).kind === 'mobile') { throw new Error('terminal_mobile_driver_active') } } }) return true } catch { return false } } hasHeadlessTerminalState(ptyId: string): boolean { return this.headlessTerminals.has(ptyId) } serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number seq?: number cwd?: string | null lastTitle?: string source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } async serializeHiddenOutputRecoveryBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number cwd?: string | null lastTitle?: string seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) if (headlessSnapshot) { return headlessSnapshot } // Why: hidden-output recovery is initiated by the desktop renderer. If the // runtime has not built headless state yet, the mounted xterm is still the // best available state and avoids a false "snapshot unavailable" result. const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) return rendererSnapshot ?? this.serializeProviderTerminalBuffer(ptyId, opts) } async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { const leaf = this.resolveLeafForHandle(handle) if (!leaf?.ptyId) { throw new Error('terminal_not_found') } // Why: clear is a terminal UI action (Cmd+K on desktop), not shell input. // Route through the controller so renderer-owned xterm buffers, daemon // sessions, and SSH relay sessions all drop scrollback before the next // mobile snapshot. await this.ptyController?.clearBuffer?.(leaf.ptyId) await this.clearHeadlessTerminalBuffer(leaf.ptyId) return { handle, cleared: true } } getTerminalSize(ptyId: string): { cols: number; rows: number } | null { return this.ptyController?.getSize?.(ptyId) ?? null } // Why: a width reflow on a normal-buffer PTY must re-stream the full // scrollback to mobile so it rewraps at the new cols, but alternate-screen // TUIs (vim, Claude Code) own their repaint and have no scrollback — for // those the mobile client just resizes xterm geometry and consumes the // TUI's own redraw, so the resize re-stream must be skipped. Provider state // covers restored PTYs whose main-side emulator is only a partial suffix. isTerminalAlternateScreen(ptyId: string): boolean { if (this.providerSnapshotPreferredPtys.has(ptyId)) { return this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false } return ( this.headlessTerminals.get(ptyId)?.emulator.isAlternateScreen ?? this.providerModeTrackersByPtyId.get(ptyId)?.isAlternateScreen ?? false ) } // Why: daemon-backed PTYs that the runtime adopted after an Orca relaunch // start with a fresh headless emulator that has zero scrollback, even though // the daemon's on-disk checkpoint and the desktop xterm both contain the // full prior history. Without this hydration, mobile subscribers see only // the bare current prompt because serializeHeadlessTerminalBuffer always // wins over the renderer-path fallback. Seeding the emulator with the // adapter's snapshot/cold-restore data makes mobile and desktop agree on // what scrollback is available. seedHeadlessTerminal( ptyId: string, data: string, size?: { cols: number; rows: number }, metadata: HeadlessSeedMetadata = {} ): void { if (!data) { return } const existing = this.headlessTerminals.get(ptyId) if (existing) { // Why: emulator already has live data — re-seeding would duplicate // every byte. The seed is only valid when the emulator is fresh. if (metadata.preferProviderIfExisting) { this.providerSnapshotPreferredPtys.add(ptyId) } return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state = this.createPtyHeadlessTerminalState(ptyId, dims) state.outputSequence = this.getPtyOutputSequence(ptyId) this.headlessTerminals.set(ptyId, state) this.recordOsc7MetadataForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) state.writeChain = state.writeChain .then(async () => { // Why: seed writes never set forwardQueryReplies — the main-side // replay guard. A snapshot containing old queries must answer no one. await state.emulator.write(data) // Why AFTER the seed write: the snapshot payload cannot carry kitty // pushes (rehydrateSequences deliberately omits them), but ordering // behind it keeps the parse deterministic. Unflagged like the seed — // re-applying flags must answer no one. if (typeof metadata.kittyKeyboardFlags === 'number') { await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) } if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) } if (metadata.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(metadata.oscLinks) } this.providerSnapshotPreferredPtys.delete(ptyId) }) .catch(() => { // Seeding is best-effort; live data will continue to populate the // emulator even if the snapshot replay fails. }) } // Why: reattach/cold-restore/replay payloads arrive as spawn RPC results and // never pass through onPtyData, so after a relaunch the records backing // `terminal list`/`terminal read` stayed blank while the session was alive. // Seed semantics (applySeededAgentStatus precedent): write state only — no // waiters, no orchestration events, and no lastOutputAt, because restored // bytes are historical output, not fresh activity. seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void { const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null if (seed) { const pty = this.getOrCreatePtyWorktreeRecord(ptyId) // Why: live bytes outrank the seed — only never-written records take it, // so a same-run remount reattach cannot re-apply history it already has. if (pty && restoredTerminalTailSeedAllowed(pty)) { applyRestoredTerminalTailSeed(pty, seed) this.primeWaitBlockedBaselineFromSeededTail(ptyId) } for (const leaf of this.getLeavesForPty(ptyId)) { if (restoredTerminalTailSeedAllowed(leaf)) { applyRestoredTerminalTailSeed(leaf, seed) } } } if (restore.lastTitle) { // Why: mirror renderer hydration — a title main already tracked live outranks the payload's persisted one. this.applySeededAgentStatus(ptyId, this.getTrackedRawTitleForPty(ptyId) ?? restore.lastTitle) } } // Why: hydrate the runtime headless emulator from the desktop renderer's // xterm buffer on the first onPtyData byte after a PTY is taken over by a // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals // is populated synchronously so concurrent live writes from // trackHeadlessTerminalData chain after the seed via the same writeChain. // See docs/mobile-prefer-renderer-scrollback.md. private maybeHydrateHeadlessFromRenderer(ptyId: string): void { if (this.headlessHydrationState.has(ptyId)) { return } const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId) if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) { // Daemon-snapshot seed already populated the emulator — skip hydration. this.headlessHydrationState.set(ptyId, 'done') return } const controller = this.ptyController if (!controller?.serializeBuffer || !controller.hasRendererSerializer) { return } if (!controller.hasRendererSerializer(ptyId)) { // Renderer hasn't registered yet (or never will). Live writes lazy- // create the state via trackHeadlessTerminalData on this same tick. return } if (providerSnapshotPreferred) { // Why: a stream byte can create a partial model before restored history // arrives. A mounted renderer snapshot can safely replace that model. this.disposeHeadlessTerminal(ptyId) } this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } // Why: hydration writes below never set forwardQueryReplies (main-side // replay guard) — renderer-buffer snapshots can embed stale queries. const state = this.createPtyHeadlessTerminalState(ptyId, dims) state.outputSequence = this.getPtyOutputSequence(ptyId) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by // trackHeadlessTerminalData (after this method returns synchronously) // execute AFTER the seed-write resolves. If we awaited inline before // setting headlessTerminals, the live byte would lazy-create a separate // state and the seed-resolve would overwrite it, dropping live bytes. state.writeChain = state.writeChain.then(async () => { try { const rendered = await controller.serializeBuffer!(ptyId, { scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, altScreenForcesZeroRows: true }) if (!rendered || rendered.data.length === 0) { return } this.recordOsc7MetadataForPty(ptyId, rendered.data) this.recordRecentPtyOutputForPathProvenance(ptyId, rendered.data) // Resize to renderer's dims so the seed reflows correctly into the // emulator's grid, then resize back to PTY dims (if known) so live // writes use the correct cell layout. if (rendered.cols !== dims.cols || rendered.rows !== dims.rows) { state.emulator.resize(rendered.cols, rendered.rows) } await state.emulator.write(rendered.data) const ptyDims = this.getTerminalSize(ptyId) if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) } // Why: the renderer xterm no longer sees synthetic hook title frames // (they feed main's tracker only), so its serializer lastTitle can be // stale here. Prefer main's tracked title; the renderer's is only the // seed when main has observed none (fresh relaunch, cold tracker). const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle if (seedTitle) { state.emulator.setLastTitle(seedTitle) this.applySeededAgentStatus(ptyId, seedTitle) } this.providerSnapshotPreferredPtys.delete(ptyId) } catch { // Hydration is best-effort. Live writes continue via the same // writeChain that this catch-arm leaves intact. } finally { this.headlessHydrationState.set(ptyId, 'done') } }) } // Why: seed-derived agent status reflects historical state. Orchestration // waiters (resolveTuiIdleWaiters, deliverPendingMessages) must only react // to LIVE transitions, so this helper writes leaf.lastAgentStatus only and // never resolves waiters. detectAgentStatusFromTitle wrap mirrors the live // path so seeded and live values are the same union member, keeping // downstream `=== 'idle'` checks correct. private applySeededAgentStatus(ptyId: string, title: string): void { if (!title) { return } // Why: a relaunched main starts its per-PTY title tracker cold — without // this seed it misses the parked working→idle completion and never arms // the stale-title timer for a persisted 'working' title. Seeding no-ops // once a live title was observed, so live state always wins. this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) // Why: live observations store normalized titles, so seeds must match — // otherwise the first live frame after hydration compares unequal and // touches session tabs once for no visible change. const seededTitle = normalizeTerminalTitle(title) const pty = this.ptysById.get(ptyId) if (pty) { const observedAt = this.nextTitleObservationSequence() pty.lastOscTitle = seededTitle pty.lastOscTitleAt = observedAt this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt) } for (const leaf of this.getLeavesForPty(ptyId)) { // Why: seed lastOscTitle even when the seeded title doesn't classify // as an agent state, so worktree.ps recomputes status from the live // title rather than treating the leaf as agentless. leaf.lastOscTitle = seededTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() if (status !== null) { leaf.lastAgentStatus = status } } } /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at * ingestion only — never re-read at reply time. */ private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { return shouldModelAnswerHiddenPtyQueries({ ptyId, settings: this.store?.getSettings(), hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) }) } private trackHeadlessTerminalData( ptyId: string, data: string, outputSequence: number, forwardQueryReplies = false ): Promise { const state = this.getOrCreateHeadlessTerminal(ptyId) const completion = state.writeChain.then(async () => { // Why: the ingestion-time ownership decision is closed over this // chain link; async scheduling cannot retroactively change it. await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) // Legacy callers remain best-effort; bounded SSH admission observes the raw receipt. state.writeChain = completion.catch(() => {}) return completion } /** Shared factory for the per-PTY runtime emulators (seed, hydration, and * lazy live-byte creation): wires the Phase-5 query-reply sink and the * ConPTY DA1 override. The daemon emulator never goes through here. */ private createPtyHeadlessTerminalState( ptyId: string, dims: { cols: number; rows: number } ): RuntimeHeadlessTerminal { let state: RuntimeHeadlessTerminal | null = null const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) const emulator = new HeadlessEmulator({ cols: dims.cols, rows: dims.rows, pathFlavor, remotePosixFileUriAuthority: !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', wslDistro: this.ptysById.get(ptyId)?.connectionId ? undefined : (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined), // Why: replies take the provider input path (same entry as pty:write — // daemon shell-ready gating and the SSH relay write apply unchanged), // NOT writePtyInput, so renderer interactive-output metering never // counts responder traffic as user-input echo. onQueryReply: (reply) => { // Why the identity check: queued writeChain links can parse after // disposeHeadlessTerminal, and daemon respawns reuse session ids — a // stale link's reply must never reach a successor PTY under this id. if (state !== null && this.headlessTerminals.get(ptyId) === state) { // Why this write is safe pre-shell-ready: daemon Session.write // QUEUES (never drops) input while the POSIX shell-ready gate is // pending and flushes at the ready marker or the 15s // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query // reply is delayed at most that bound, not lost. this.ptyController?.write(ptyId, reply) } } }) if (isNativeWindowsConptyPty(ptyId)) { emulator.installConptyPrimaryDeviceAttributesOverride() } // Why the lazy getter: replies must use the freshest renderer push at // parse time, and stay silent (never default) before the first push. emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) const viewAttributes = getTerminalViewAttributes() if (viewAttributes) { emulator.applyPushedViewAttributes(viewAttributes) } state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } return state } /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via * markNativeWindowsConptyPty when the spawn mark lands after daemon stream * data already created this PTY's emulator. Idempotent emulator-side. */ private ensureNativeWindowsConptyDa1Override(ptyId: string): void { if (isNativeWindowsConptyPty(ptyId)) { this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() } } private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { const existing = this.headlessTerminals.get(ptyId) if (existing) { return existing } const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state = this.createPtyHeadlessTerminalState(ptyId, size) this.headlessTerminals.set(ptyId, state) return state } private replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void { this.disposeHeadlessTerminal(ptyId) this.providerSnapshotPreferredPtys.add(ptyId) const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) state.writeChain = state.writeChain .then(async () => { const snapshot = await this.serializeProviderTerminalBuffer(ptyId) if (!snapshot) { return } const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` // Why: a newer live OSC 7 can arrive while the snapshot is in flight; // only seed metadata while no post-correction CWD has won the race. if (!this.terminalCwdByPtyId.has(ptyId)) { this.recordOsc7MetadataForPty(ptyId, data) } await state.emulator.write(data) if (snapshot.cwd !== undefined) { state.emulator.setCwd(snapshot.cwd) if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) { this.terminalCwdByPtyId.set(ptyId, snapshot.cwd) } } if (snapshot.oscLinks !== undefined) { state.emulator.setRestoredOscLinks(snapshot.oscLinks) } state.outputSequence = snapshot.seq }) .catch(() => { // Best-effort: live bytes already chain behind this replacement state. }) .finally(() => { this.providerSnapshotPreferredPtys.delete(ptyId) }) } private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { const state = this.headlessTerminals.get(ptyId) if (!state) { return } // Why: terminal reflow is a parser operation. It must sit in the same // per-PTY stream as output bytes or restore snapshots can bake in wraps // from the wrong terminal width. state.writeChain = state.writeChain .then(() => { state.emulator.resize(cols, rows) }) .catch(() => { // Best-effort mirror tracking; live PTY streaming must continue even // if xterm rejects a raced resize during teardown. }) } // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile // mirror or a resubscribing mobile client resurrects the cleared scrollback. async clearHeadlessTerminalBuffer(ptyId: string): Promise { const state = this.headlessTerminals.get(ptyId) if (!state) { return } // Why: headless writes are queued to preserve xterm parser order. Clear // must join that same chain or an earlier PTY chunk can finish after the // clear request and repopulate mobile scrollback. state.writeChain = state.writeChain.then(() => state.emulator.clearScrollback()) await state.writeChain } private async serializeTerminalBufferFromAvailableState( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number cwd?: string | null lastTitle?: string seq?: number source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean pendingEscapeTailAnsi?: string } | null> { if (this.providerSnapshotPreferredPtys.has(ptyId)) { // Why: pre-attach stream bytes only form a suffix of restored state. A // sequenced provider snapshot safely reconciles live bytes; renderer is // the fallback when an older provider cannot expose that boundary. const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) if (providerSnapshot) { return providerSnapshot } const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) if (rendererSnapshot) { return rendererSnapshot } } const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { return headlessSnapshot } const rendererSnapshot = await this.serializeRendererTerminalBuffer(ptyId, opts) if (!rendererSnapshot) { return this.serializeProviderTerminalBuffer(ptyId, opts) } if (rendererSnapshot.data.length > 0) { return rendererSnapshot } // Why: parked desktop panes register serializers before their xterm has // hydrated. Treat that empty shell as provisional so retained provider // history can restore mobile without forcing the desktop pane to mount. const providerSnapshot = await this.serializeProviderTerminalBuffer(ptyId, opts) return providerSnapshot && (providerSnapshot.data.length > 0 || Boolean(providerSnapshot.scrollbackAnsi)) ? providerSnapshot : rendererSnapshot } async serializeRendererTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} ): Promise<{ data: string cols: number rows: number seq?: number cwd?: string | null lastTitle?: string source?: 'renderer' oscLinks?: TerminalOscLinkRange[] } | null> { if (this.ptyController?.hasRendererSerializer?.(ptyId) === false) { return null } let rendererSnapshot: { data: string cols: number rows: number seq?: number cwd?: string | null lastTitle?: string oscLinks?: TerminalOscLinkRange[] } | null = null try { // Why: recovery/read fallback wants visible alt-screen content (e.g. an // active TUI), so altScreenForcesZeroRows is FALSE here. Hydration is // the only path that suppresses alt-screen scrollback. rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, { scrollbackRows: opts.scrollbackRows, altScreenForcesZeroRows: false }) ?? Promise.resolve(null)) } catch { // Why: terminal snapshots should not depend on a mounted renderer pane. // If renderer serialization races reload/unmount, callers can still use // their existing null fallback paths. } return rendererSnapshot ? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), source: 'renderer' as const }) : null } private async serializeProviderTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {}, wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {} ): Promise { const generation = this.getPtyLifecycleGeneration(ptyId) const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0)) let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId) if ( !acquisition || acquisition.generation !== generation || acquisition.scrollbackRows < scrollbackRows ) { const promise = this.captureProviderTerminalBuffer(ptyId, opts, generation) acquisition = { generation, scrollbackRows, promise, timedOut: false } this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition) void promise.finally(() => { if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) { this.providerBufferAcquisitionsByPtyId.delete(ptyId) } }) } if (acquisition.timedOut) { return null } if (typeof wait.timeoutMs !== 'number') { return acquisition.promise } const result = await withTimeout< { settled: true; value: PtyProviderBufferSnapshot | null } | { settled: false } >( acquisition.promise.then((value) => ({ settled: true as const, value })), wait.timeoutMs, { settled: false as const } ) if (!result.settled) { if (wait.retireOnTimeout) { acquisition.timedOut = true } return null } return result.value } private async captureProviderTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number }, generation: number ): Promise { const liveModeTracker = new TerminalKittyKeyboardModeTracker() let liveModeTrackers = this.providerModeSnapshotScansByPtyId.get(ptyId) if (!liveModeTrackers) { liveModeTrackers = new Set() this.providerModeSnapshotScansByPtyId.set(ptyId, liveModeTrackers) } liveModeTrackers.add(liveModeTracker) try { // Why: daemon PTYs survive an app relaunch before any renderer mounts. // Mobile still needs their retained history without navigating desktop. const snapshot = await this.ptyController?.serializeProviderBuffer?.(ptyId, opts) if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { return null } const snapshotModeTracker = new TerminalKittyKeyboardModeTracker() if (typeof snapshot.alternateScreen === 'boolean') { snapshotModeTracker.scan(snapshot.alternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') } else { // Why: older providers omit mode metadata, but their ANSI snapshot // still carries the DECSET/DECRST needed to classify the active screen. snapshotModeTracker.scanReplay(snapshot.data) } const observedSnapshotMode = snapshotModeTracker.hasObservedAlternateScreenSwitch let effectiveAlternateScreen: boolean | undefined if (observedSnapshotMode || liveModeTracker.hasObservedAlternateScreenSwitch) { const modeTracker = new TerminalKittyKeyboardModeTracker() if (observedSnapshotMode) { modeTracker.scan(snapshotModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') } // Why: stream bytes received after the request began can be newer // than snapshot metadata, so an observed live transition wins. if (liveModeTracker.hasObservedAlternateScreenSwitch) { modeTracker.scan(liveModeTracker.isAlternateScreen ? '\x1b[?1049h' : '\x1b[?1049l') } this.providerModeTrackersByPtyId.set(ptyId, modeTracker) effectiveAlternateScreen = modeTracker.isAlternateScreen } const providerOffset = this.providerSequenceOffsetByPtyId.get(ptyId) ?? 0 const reconciledSnapshot = this.preferTrackedLastTitle(ptyId, { ...snapshot, seq: providerOffset + snapshot.seq, ...(effectiveAlternateScreen !== undefined ? { alternateScreen: effectiveAlternateScreen } : {}) }) if (liveModeTracker.hasObservedAlternateScreenSwitch) { this.providerSnapshotsWithLiveModeTransition.add(reconciledSnapshot) } return reconciledSnapshot } catch { return null } finally { liveModeTrackers.delete(liveModeTracker) if (liveModeTrackers.size === 0) { this.providerModeSnapshotScansByPtyId.delete(ptyId) } } } private async withVisibleSnapshotFallback( ptyId: string, read: RuntimeTerminalRead, opts: { cursor?: number; limit?: number } = {} ): Promise { if (typeof opts.cursor === 'number') { return read } const blankFallback = shouldFallbackToVisibleTerminalSnapshot(read, opts) const recoveredWorkerFallback = read.tail.length === 0 && this.legacyWorkerRecoveredPtys.has(ptyId) // Why: a live daemon session no pane ever attached has ingested zero bytes, // so only the provider holds its screen. Unprovable state stays empty. const neverAttachedProviderFallback = read.tail.length === 0 && !recoveredWorkerFallback && this.isKnownUnattachedLocalDaemonPty(ptyId) if (recoveredWorkerFallback || neverAttachedProviderFallback) { const providerLines = await this.readProviderTerminalTailLines(ptyId, opts.limit) if (providerLines.length > 0) { return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit) } } const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) const providerModeUnknown = this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) if ( !blankFallback && !recoveredWorkerFallback && !providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId) ) { return read } const visibleState = await this.readVisibleTerminalState(ptyId) if ( !blankFallback && !recoveredWorkerFallback && !knownAlternateScreen && !visibleState?.isAlternateScreen ) { return read } let lines = visibleState?.lines ?? [] if (lines.length === 0) { lines = await this.readRendererVisibleSnapshotLines(ptyId) } if (lines.length === 0) { return read } return buildVisibleSnapshotReadFallback(read, lines, opts.limit) } private async readProviderTerminalTailLines( ptyId: string, limit: number | undefined ): Promise { const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const snapshot = await this.serializeProviderTerminalBuffer(ptyId, { scrollbackRows: lineLimit }) const data = snapshot ? `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` : '' if (!snapshot || data.length === 0) { return [] } const emulator = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows, scrollback: lineLimit }) try { await emulator.write(data) return visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit)) } finally { emulator.dispose() } } private async visibleSnapshotPreview(ptyId: string, preview: string): Promise { const knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) const providerModeUnknown = this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) if (!providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId)) { return preview } const visibleState = await this.readVisibleTerminalState(ptyId) if (!knownAlternateScreen && !visibleState?.isAlternateScreen) { return preview } let lines = visibleState?.lines ?? [] if (lines.length === 0) { lines = await this.readRendererVisibleSnapshotLines(ptyId) } return lines.length > 0 ? buildPreview(lines, '') : preview } private async readVisibleTerminalState( ptyId: string ): Promise { if (!this.providerSnapshotPreferredPtys.has(ptyId)) { return this.readHeadlessVisibleTerminalState(ptyId) } const generation = this.getPtyLifecycleGeneration(ptyId) const outputSequence = this.getPtyOutputSequence(ptyId) const cached = this.providerVisibleStateByPtyId.get(ptyId) const trackedMode = this.providerModeTrackersByPtyId.get(ptyId) if ( cached?.generation === generation && outputSequence <= cached.sequence && (!trackedMode || trackedMode.isAlternateScreen === cached.isAlternateScreen) ) { return cached } if (trackedMode && !trackedMode.isAlternateScreen) { const headlessState = await this.readHeadlessVisibleTerminalState(ptyId) return headlessState ? { ...headlessState, isAlternateScreen: false } : { lines: [], isAlternateScreen: false, sequence: outputSequence, generation } } if ((this.providerVisibleRetryAtByPtyId.get(ptyId) ?? 0) > Date.now()) { return null } const snapshot = await this.serializeProviderTerminalBuffer( ptyId, { scrollbackRows: 0 }, { timeoutMs: VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS } ) if (!snapshot || this.getPtyLifecycleGeneration(ptyId) !== generation) { this.providerVisibleRetryAtByPtyId.set(ptyId, Date.now() + VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS) return null } this.providerVisibleRetryAtByPtyId.delete(ptyId) if (this.providerSnapshotsWithLiveModeTransition.has(snapshot)) { // Why: the provider frame can predate a mode switch observed while its // RPC was pending; the ordered live emulator owns the post-switch grid. const liveState = await this.readHeadlessVisibleTerminalState(ptyId) if (liveState && liveState.isAlternateScreen === (snapshot.alternateScreen ?? false)) { return liveState } } const lines = await this.parseVisibleSnapshotLines(snapshot) if (this.getPtyLifecycleGeneration(ptyId) !== generation) { return null } const visibleState: RuntimeVisibleTerminalState = { lines, isAlternateScreen: snapshot.alternateScreen ?? false, sequence: snapshot.seq, generation } if (this.getPtyOutputSequence(ptyId) <= snapshot.seq) { this.providerVisibleStateByPtyId.set(ptyId, visibleState) } return visibleState } private async readHeadlessVisibleTerminalState( ptyId: string ): Promise { const state = this.headlessTerminals.get(ptyId) if (!state) { return null } const generation = this.getPtyLifecycleGeneration(ptyId) await state.writeChain if ( this.headlessTerminals.get(ptyId) !== state || this.getPtyLifecycleGeneration(ptyId) !== generation ) { return null } return { lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), isAlternateScreen: state.emulator.isAlternateScreen, sequence: state.outputSequence, generation } } private async parseVisibleSnapshotLines(snapshot: { data: string cols: number rows: number }): Promise { if (snapshot.data.length === 0) { return [] } const emulator = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows, scrollback: 0 }) try { await emulator.write(`\x1b[2J\x1b[3J\x1b[H${snapshot.data}`) return visibleNonBlankTerminalLines(emulator.getVisibleLines()) } finally { emulator.dispose() } } private async readRendererVisibleSnapshotLines(ptyId: string): Promise { const controller = this.ptyController if (!controller?.serializeBuffer) { return [] } if (controller.hasRendererSerializer && !controller.hasRendererSerializer(ptyId)) { return [] } try { // Why: raw PTY tails can be whitespace-only while a full-screen TUI is // visibly nonblank in renderer xterm. Ask the renderer for the active // screen instead of reusing the headless transcript path. const snapshot = await withTimeout( controller.serializeBuffer(ptyId, { scrollbackRows: 0, altScreenForcesZeroRows: false }), VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS, null ) if (!snapshot || snapshot.data.length === 0) { return [] } return this.parseVisibleSnapshotLines(snapshot) } catch { return [] } } private async serializeHeadlessTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} ): Promise<{ data: string cols: number rows: number cwd?: string | null lastTitle?: string seq?: number source?: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean scrollbackAnsi?: string // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it // literally (Bug E / #7329). pendingEscapeTailAnsi?: string } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { return null } await state.writeChain // Why: normal history is separated from an active alternate frame, so the // caller's scrollback policy can be honored without painting it into alt. const isAlternateScreen = state.emulator.isAlternateScreen const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { data, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), lastTitle: snapshot.lastTitle, seq: state.outputSequence, source: 'headless' as const, oscLinks: snapshot.oscLinks, scrollbackAnsi: snapshot.scrollbackAnsi, ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. alternateScreen: isAlternateScreen, // Why NOT folded into data: the renderer writes its post-replay // reset after data, and any ESC after a dangling partial aborts it. // The restorer writes this last (Bug E fix). pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }) : null } private disposeHeadlessTerminal(ptyId: string): void { this.headlessHydrationState.delete(ptyId) const state = this.headlessTerminals.get(ptyId) if (!state) { return } this.headlessTerminals.delete(ptyId) // Why: queued chain links still parse below before the emulator disposes; // sever the reply sink now so they cannot write to a respawned PTY that // reused this id (belt to the sink's state-identity check). state.emulator.disableQueryReplyForwarding() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } resolveLeafForHandle(handle: string): { ptyId: string | null } | null { const record = this.handles.get(handle) if (!record) { return null } if (record.tabId.startsWith('pty:')) { return { ptyId: record.ptyId } } const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) if (!leaf) { return null } return { ptyId: leaf.ptyId } } // Why: remote clients hold handles across transport reconnects. A handle // minted for a concrete PTY must never silently adopt a different PTY that // later occupies the same pane — that misroutes keystrokes (#7718). Handles // still awaiting their first PTY (ptyId null) may adopt it, which preserves // the mobile pre-spawn subscribe flow. resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null { const record = this.handles.get(handle) if (!record) { return null } if (record.tabId.startsWith('pty:')) { return { ptyId: record.ptyId } } const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) if (!leaf) { return null } if ( record.ptyId !== null && (leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) ) { throw new Error('terminal_handle_stale') } return { ptyId: leaf.ptyId } } getOrchestrationCompatibilityHostId(): 'local' { return 'local' } registerOrchestrationCompatibilitySshAttachment( targetId: string, connectionIncarnation: string ): OrchestrationCompatibilitySshAttachmentAuthority { const authority = Object.freeze({ kind: 'ssh' as const, targetId, connectionIncarnation, attachmentId: randomUUID() }) this.orchestrationCompatibilitySshAttachments.set(authority.attachmentId, authority) return authority } releaseOrchestrationCompatibilitySshAttachment(attachmentId: string): void { this.orchestrationCompatibilitySshAttachments.delete(attachmentId) } verifyOrchestrationCompatibilityCaller( evidence: OrchestrationCompatibilityEvidence | null | undefined ): OrchestrationCompatibilityCallerAuthority | null { const terminalHandle = typeof evidence?.terminalHandle === 'string' ? evidence.terminalHandle.trim() : '' const claimedPaneKey = typeof evidence?.paneKey === 'string' ? evidence.paneKey.trim() : '' const launchToken = typeof evidence?.launchToken === 'string' ? evidence.launchToken.trim() : '' const host = evidence?.host if (!terminalHandle || !claimedPaneKey || !launchToken) { return null } const terminal = this.getOrchestrationDispatchAuthority(terminalHandle) if ( !terminal?.processIncarnation || !terminal.paneKey || !this.orchestrationCompatibilityHostMatches(terminal.hostScope, host) ) { return null } const launchTokenHash = createHash('sha256').update(launchToken).digest('hex') let terminalProvenance: 'current_runtime' | 'restored' if (terminal.launchTokenHash) { if (launchTokenHash !== terminal.launchTokenHash) { return null } terminalProvenance = 'current_runtime' } else { const receipt = this.restoredOrchestrationAuthorityByPtyId.get(terminal.ptyId) if ( !receipt || receipt.ptyId !== terminal.ptyId || receipt.worktreeId !== terminal.worktreeId || receipt.terminalHandle !== terminal.terminalHandle || receipt.paneKey !== terminal.paneKey || receipt.processIncarnation !== terminal.processIncarnation || !this.orchestrationCompatibilityHostScopesEqual(receipt.hostScope, terminal.hostScope) ) { return null } terminalProvenance = 'restored' } const attestation = this.attestAgentHookCompatibilityAuthorityFn?.({ paneKey: claimedPaneKey, launchTokenHash, connectionId: terminal.hostScope.kind === 'ssh' ? terminal.hostScope.targetId : null, terminalProvenance }) if (!attestation || attestation.paneKey !== terminal.paneKey) { return null } return Object.freeze({ hostScope: Object.freeze({ ...terminal.hostScope }), paneKey: attestation.paneKey, terminalHandle, processIncarnation: terminal.processIncarnation, launchTokenHash }) } private orchestrationCompatibilityHostMatches( hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'], host: OrchestrationCompatibilityHostStamp | undefined ): boolean { if (hostScope.kind === 'local') { return host === undefined } if (hostScope.kind === 'wsl') { return ( host?.kind === 'wsl' && host.hostId === hostScope.hostId && host.distro === hostScope.distro ) } if (host?.kind !== 'ssh' || host.targetId !== hostScope.targetId) { return false } const authority = this.orchestrationCompatibilitySshAttachments.get(host.attachmentId) return ( authority?.targetId === host.targetId && authority.connectionIncarnation === host.connectionIncarnation ) } private orchestrationCompatibilityHostScopesEqual( left: OrchestrationCompatibilityTerminalAuthority['hostScope'], right: OrchestrationCompatibilityTerminalAuthority['hostScope'] ): boolean { if (left.kind !== right.kind) { return false } if (left.kind === 'local' && right.kind === 'local') { return left.hostId === right.hostId } if (left.kind === 'wsl' && right.kind === 'wsl') { return left.hostId === right.hostId && left.distro === right.distro } return left.kind === 'ssh' && right.kind === 'ssh' && left.targetId === right.targetId } private getOrchestrationCompatibilityHostScope( pty: RuntimePtyWorktreeRecord ): OrchestrationCompatibilityTerminalAuthority['hostScope'] | null { if (pty.connectionId) { return { kind: 'ssh', targetId: pty.connectionId } } if (pty.isWsl || pty.wslDistro) { return pty.wslDistro ? { kind: 'wsl', hostId: 'local', distro: pty.wslDistro } : null } return { kind: 'local', hostId: 'local' } } private rememberRestoredOrchestrationAuthority( pty: RuntimePtyWorktreeRecord, terminalHandle: string, incarnationId: string ): void { const paneKey = pty.paneKey const hostScope = this.getOrchestrationCompatibilityHostScope(pty) if (!paneKey || !parsePaneKey(paneKey) || !hostScope) { this.restoredOrchestrationAuthorityByPtyId.delete(pty.ptyId) return } this.restoredOrchestrationAuthorityByPtyId.set( pty.ptyId, Object.freeze({ ptyId: pty.ptyId, worktreeId: pty.worktreeId, terminalHandle, paneKey, processIncarnation: `${pty.ptyId}:${incarnationId}`, hostScope: Object.freeze({ ...hostScope }) }) ) } getOrchestrationDispatchAuthority( terminalHandle: string ): OrchestrationCompatibilityTerminalAuthority | null { let ptyId: string | null try { ptyId = this.getLivePtyForHandle(terminalHandle)?.pty.ptyId ?? this.resolveLiveLeafForHandle(terminalHandle)?.ptyId ?? null } catch { return null } if (!ptyId) { return null } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { return null } const hostScope = this.getOrchestrationCompatibilityHostScope(pty) if (!hostScope) { return null } return { runtimeId: this.runtimeId, terminalHandle, ptyId, worktreeId: pty.worktreeId, processIncarnation: this.getTerminalProcessIncarnation(terminalHandle), paneKey: pty.paneKey, launchTokenHash: pty.launchToken ? createHash('sha256').update(pty.launchToken).digest('hex') : null, hostScope } } private retirePtyAgentLaunchAuthority(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (!pty) { return } const receipt = this.restoredOrchestrationAuthorityByPtyId.get(ptyId) if (!pty.launchToken && !receipt) { return } this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null const paneKeys = new Set() if (pty.paneKey && parsePaneKey(pty.paneKey)) { paneKeys.add(pty.paneKey) } if (receipt?.paneKey && parsePaneKey(receipt.paneKey)) { paneKeys.add(receipt.paneKey) } for (const leaf of this.getLeavesForPty(ptyId)) { if (isValidTerminalTabId(leaf.tabId) && isTerminalLeafId(leaf.leafId)) { paneKeys.add(makePaneKey(leaf.tabId, leaf.leafId)) } } for (const paneKey of paneKeys) { this.retireAgentHookCompatibilityAuthorityFn?.(paneKey) } } async resolveTerminalCwd(handle: string): Promise { const ptyId = this.resolveLeafForHandle(handle)?.ptyId if (!ptyId) { return null } const tracked = this.terminalCwdByPtyId.get(ptyId) if (tracked) { return tracked } try { const cwd = await this.ptyController?.getCwd?.(ptyId) return cwd && cwd.trim().length > 0 ? cwd : null } catch { return null } } resolveTerminalFileUriHostname(handle: string): string | null { const ptyId = this.resolveLeafForHandle(handle)?.ptyId return ptyId ? (this.terminalFileUriHostnameByPtyId.get(ptyId) ?? null) : null } private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { let recentOutputBuffer = this.recentPtyOutputById.get(ptyId) if (!recentOutputBuffer) { // Boundaries are only owed to the one-time activation backfill; once // tracking is live, new buffers keep the read-collapsing hot path. recentOutputBuffer = new RecentPtyOutputBuffer({ preserveChunkBoundaries: !this.recentPtyPathCandidateTrackingActive }) this.recentPtyOutputById.set(ptyId, recentOutputBuffer) } recentOutputBuffer.append(data) if ( this.recentPtyPathCandidateTrackingActive || // Why: an over-window chunk is stored pre-sliced, so activation backfill // could never replay its original text. Extract while intact; oversized // chunks are rare, so the desktop-only gate still skips the hot path. data.length > RECENT_PTY_OUTPUT_LIMIT ) { this.recentPtyPathCandidatesById.set( ptyId, appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) ) } } activateRecentPtyPathCandidateTracking(): void { if (this.recentPtyPathCandidateTrackingActive) { return } this.recentPtyPathCandidateTrackingActive = true // Why: synchronous backfill from the retained raw windows so a file tap // right after first mobile connect resolves exactly as before the gate. // Replay each retained chunk in its original full form: joining or // trimming chunks would change the candidate set (e.g. a window cut can // shorten an over-4KiB line under the extractor's line guard, minting // candidates the eager extractor rejected). // Accepted best-effort loss: output that scrolled past the raw window // before the first-ever connect no longer yields candidates. for (const [ptyId, buffer] of this.recentPtyOutputById) { let candidates = this.recentPtyPathCandidatesById.get(ptyId) const { chunks, headChunkIsPartial } = buffer.retainedChunks() for (let index = 0; index < chunks.length; index += 1) { if (index === 0 && headChunkIsPartial) { // A pre-sliced over-window chunk was already extracted eagerly at // append time (while its original text was intact); replaying its // truncated remainder would mint or drop candidates spuriously. continue } candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) } if (candidates) { this.recentPtyPathCandidatesById.set(ptyId, candidates) } // Chunk boundaries were owed only to this one-time backfill; return // the buffer to the compact read-collapsing steady state. buffer.compact() } } resolveTerminalContext( handle: string ): { worktreeId: string; connectionId: string | null } | null { const ptyId = this.resolveLeafForHandle(handle)?.ptyId const pty = ptyId ? this.ptysById.get(ptyId) : null return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null } // Why: remote clients cannot resolve this runtime's WSL project preference, // so host-affecting RPCs (skill discovery) resolve it from the owning store. resolveProjectRuntimeForWorktree( worktreeId: string | null | undefined ): ProjectExecutionRuntimeResolution | undefined { return this.store && worktreeId ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), worktreeId) : undefined } getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { let pty: RuntimePtyWorktreeRecord | null = null try { const ptyId = this.resolveLeafForHandle(handle)?.ptyId pty = ptyId ? (this.ptysById.get(ptyId) ?? null) : null } catch { return 'orca' } if (!pty) { return 'orca' } return resolveTerminalOrchestrationCliCommand({ connectionId: pty.connectionId, isWsl: pty.isWsl, worktreeId: pty.worktreeId, projectRuntime: this.store ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) : undefined }) } hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { // Why: safety net for any query path that never saw a mobile onReady — // lazily backfill so the answer matches pre-gate behavior. if (!this.recentPtyPathCandidateTrackingActive) { this.activateRecentPtyPathCandidateTracking() } const ptyId = this.resolveLeafForHandle(handle)?.ptyId const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId)?.read() : null if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { return true } const candidates = ptyId ? this.recentPtyPathCandidatesById.get(ptyId) : null return candidates ? recentTerminalPathCandidatesIncludePath(candidates, pathText, absolutePath) : false } registerSubscriptionCleanup( subscriptionId: string, cleanup: () => void | Promise, connectionId?: string ): void { // Why: mobile clients reconnect frequently (phone lock, network switch). // The RPC client re-sends terminal.subscribe on reconnect, creating a new // handler before the old one is cleaned up. Without this, the old data // listener leaks in dataListeners and duplicates every PTY data event. const existing = this.subscriptionCleanups.get(subscriptionId) if (existing) { // Why: the stable id is about to belong to a newer connection; detach // the old owner before its asynchronous cleanup can overlap the rebind. this.removeSubscriptionConnectionIndex(subscriptionId) this.cleanupSubscription(subscriptionId) } this.subscriptionCleanups.set(subscriptionId, cleanup) if (connectionId) { let set = this.subscriptionsByConnection.get(connectionId) if (!set) { set = new Set() this.subscriptionsByConnection.set(connectionId, set) } set.add(subscriptionId) this.subscriptionConnectionByEntry.set(subscriptionId, connectionId) } } cleanupSubscription(subscriptionId: string): void { void this.cleanupSubscriptionAndWait(subscriptionId).catch((error) => { console.error(`[runtime] subscription cleanup failed for ${subscriptionId}:`, error) }) } retrySubscriptionCleanupAfter( subscriptionId: string, cleanupOwner: () => void | Promise, gate: Promise ): void { const failedGeneration = this.subscriptionCleanupPromises.get(subscriptionId) void gate.then( async () => { await (failedGeneration?.cleanup === cleanupOwner ? failedGeneration.promise.catch(() => undefined) : undefined) while (this.subscriptionCleanups.get(subscriptionId) === cleanupOwner) { const newerGeneration = this.subscriptionCleanupPromises.get(subscriptionId) if (newerGeneration?.cleanup === cleanupOwner) { // Why: a caller may already be retrying this owner; wait for that // exact generation so a rejected join cannot consume our retry. await newerGeneration.promise.catch(() => undefined) continue } this.cleanupSubscription(subscriptionId) return } }, () => undefined ) } async cleanupSubscriptionAndWait(subscriptionId: string): Promise { const cleanup = this.subscriptionCleanups.get(subscriptionId) if (!cleanup) { return } const inFlight = this.subscriptionCleanupPromises.get(subscriptionId) if (inFlight?.cleanup === cleanup) { return inFlight.promise } let cleanupResult: void | Promise try { cleanupResult = cleanup() } catch (error) { cleanupResult = Promise.reject(error) } const promise = Promise.resolve(cleanupResult) .then(() => { // Why: a reconnect can replace this id while old async cleanup runs; // only the generation that registered this callback may remove it. if (this.subscriptionCleanups.get(subscriptionId) !== cleanup) { return } this.subscriptionCleanups.delete(subscriptionId) this.removeSubscriptionConnectionIndex(subscriptionId) }) .finally(() => { if (this.subscriptionCleanupPromises.get(subscriptionId)?.promise === promise) { this.subscriptionCleanupPromises.delete(subscriptionId) } }) this.subscriptionCleanupPromises.set(subscriptionId, { cleanup, promise }) return promise } private removeSubscriptionConnectionIndex(subscriptionId: string): void { const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId) if (connectionId) { this.subscriptionConnectionByEntry.delete(subscriptionId) const set = this.subscriptionsByConnection.get(connectionId) if (set) { set.delete(subscriptionId) if (set.size === 0) { this.subscriptionsByConnection.delete(connectionId) } } } } cleanupSubscriptionsByPrefix(prefix: string): void { const ids = Array.from(this.subscriptionCleanups.keys()).filter((id) => id.startsWith(prefix)) for (const id of ids) { this.cleanupSubscription(id) } } // Why: invoked from the WebSocket transport's on-close hook so streaming // listeners registered for this exact socket get torn down even when other // sockets sharing the same deviceToken are still alive (multi-screen // mobile). Without this sweep, listeners leak across every reconnect. cleanupSubscriptionsForConnection(connectionId: string): void { const set = this.subscriptionsByConnection.get(connectionId) if (!set) { return } // Why: snapshot the ids before iterating because cleanupSubscription // mutates both the set and the index map. const ids = Array.from(set) for (const id of ids) { if (this.subscriptionConnectionByEntry.get(id) !== connectionId) { set.delete(id) continue } this.cleanupSubscription(id) } if (set.size === 0) { this.subscriptionsByConnection.delete(connectionId) } } // Why: mobile clients subscribe via notifications.subscribe streaming RPC. // Each subscriber gets its own listener. Returns an unsubscribe function // that the subscription cleanup mechanism calls on disconnect. onNotificationDispatched(listener: (event: MobileNotificationEvent) => void): () => void { this.notificationListeners.add(listener) return () => { this.notificationListeners.delete(listener) } } getMobileNotificationListenerCount(): number { return this.notificationListeners.size } // Why: bounded replay buffer for the mobile reconnect catch-up (#8129). // Every dispatched notification is recorded with a monotonic seq so a // reconnecting client can fetch exactly the events it missed. Kept on the // service instance (not per-client) because the buffer is a global, // idempotent-by-seq source of truth; clients watermark their own position. private readonly mobileNotificationReplay = new MobileNotificationReplayBuffer() dispatchMobileNotification(event: MobileNotificationEvent): void { const seq = this.mobileNotificationReplay.record(event) // Why: surface the desktop-assigned seq to live listeners so they can watermark the last event // delivered and feed it back to getMissedSince on reconnect (idempotent catch-up, no dupes). notifyRuntimeListeners( this.notificationListeners, (listener) => listener({ ...event, notificationSeq: seq, notificationEpoch: this.mobileNotificationReplay.epoch }), 'mobile-notification' ) } // Returns notifications dispatched after lastSeenSeq. Idempotent: the same // watermark always yields the same set, so a client cannot be re-pushed an // already-delivered event (the adversarial-review gate for #8129). getMissedNotificationsSince(lastSeenSeq: number, epoch?: string): ReplayableMobileNotification[] { return this.mobileNotificationReplay.getMissedSince(lastSeenSeq, epoch) } // Why (#8591): the seq counter is per-process and restarts at 0 on every desktop // launch, but the client's watermark is persisted. Clients need the epoch to tell // a stale watermark from a valid one — see MobileNotificationReplayBuffer. getMobileNotificationEpoch(): string { return this.mobileNotificationReplay.epoch } dismissMobileNotification(notificationId: string): void { this.dispatchMobileNotification({ type: 'dismiss', notificationId }) } /** Plugin panel action notifications.show. Native on desktop, relayed to * paired mobile clients either way (mirrors notifications:dispatch). */ async dispatchPluginNotification(input: { pluginId: string title: string body?: string }): Promise<{ delivered: boolean }> { // Why: prefix with the plugin id so a plugin cannot spoof an Orca system // notification or impersonate another plugin. const title = `${input.pluginId}: ${input.title}` const body = input.body ?? '' let delivered = false try { if (Notification.isSupported()) { new Notification({ title, body }).show() delivered = true } } catch { // Headless serve has no notification display; the mobile relay below // still runs. } this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) return { delivered } } // ─── Account Services (mobile RPC bridge) ───────────────────── setAccountServices(services: RuntimeAccountServices): void { this.accountServices = services } setCommitMessageAgentEnvironmentResolvers( resolvers: CommitMessageAgentEnvironmentResolvers ): void { this.commitMessageAgentEnv = resolvers } getCommitMessageAgentEnvironmentResolvers(): CommitMessageAgentEnvironmentResolvers | undefined { return this.commitMessageAgentEnv ?? undefined } // Lists the speech-model catalog joined with live download/ready state, plus // the current enabled flag + selected model, so mobile can present a dictation // setup sheet and drive remote enable/download. Always targets this (paired) // desktop — speech never routes to a worktree's SSH host. async listMobileSpeechModels(): Promise { if (!this.store) { throw new Error('voice_dictation_unavailable') } const voice = this.store.getSettings().voice ?? getDefaultVoiceSettings() const states = await getSpeechModelManager(this.store).getModelStates() const stateById = new Map(states.map((state) => [state.id, state])) const models: RuntimeSpeechModelSummary[] = SPEECH_MODEL_CATALOG.map((manifest) => { const state = stateById.get(manifest.id) return { id: manifest.id, label: manifest.label, provider: manifest.provider === 'openai' ? 'openai' : 'local', sizeBytes: manifest.sizeBytes ?? null, recommended: manifest.recommended === true, status: state?.status ?? 'not-downloaded', progress: state?.progress ?? null } }) return { enabled: voice.enabled === true, selectedModelId: voice.sttModel ?? '', dictationMode: voice.dictationMode === 'hold' ? 'hold' : 'toggle', models } } // Fire-and-forget model download; the ModelManager writes progress into its // per-model state, which mobile reads back via listMobileSpeechModels polling. async downloadMobileSpeechModel(modelId: string): Promise<{ started: true }> { if (!this.store) { throw new Error('voice_dictation_unavailable') } const manifest = getCatalogModel(modelId) if (!manifest || !isLocalSpeechModel(manifest)) { throw new Error('voice_model_not_downloadable') } // Why: do not await — downloads run for tens of seconds; the call returns // immediately and mobile polls for progress/ready. void getSpeechModelManager(this.store) .downloadModel(modelId) .catch((err) => { console.error('[runtime] mobile speech model download failed', { modelId, err }) }) return { started: true } } async deleteMobileSpeechModel(modelId: string): Promise { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('voice_dictation_unavailable') } const store = this.store try { // The runtime store is adapted to the minimal speech settings contract used by deletion. await deleteLocalSpeechModel({ store: { getSettings: () => store.getSettings(), updateSettings: (updates, options) => store.updateSettings?.(updates, options) }, modelManager: getSpeechModelManager(store), sttService: getSpeechSttService(store), modelId }) } catch (error) { throw new Error(getSpeechModelDeletionErrorCode(error) ?? 'voice_model_delete_failed') } return this.listMobileSpeechModels() } // Enables/disables dictation and/or selects the model, merging into the // existing voice settings so other voice fields are preserved. async configureMobileDictation(params: { enabled?: boolean modelId?: string dictationMode?: 'toggle' | 'hold' }): Promise { if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('voice_dictation_unavailable') } const current = this.store.getSettings().voice ?? getDefaultVoiceSettings() // An explicit '' clears the selected model (the OptionalString RPC schema // maps '' → undefined, so this only matters for direct callers); any other // non-empty modelId must be a known catalog entry. if (params.modelId !== undefined && params.modelId !== '' && !getCatalogModel(params.modelId)) { throw new Error('voice_model_unknown') } const nextVoice: VoiceSettings = { ...current, ...(params.enabled !== undefined ? { enabled: params.enabled } : {}), ...(params.modelId !== undefined ? { sttModel: params.modelId } : {}), ...(params.dictationMode !== undefined ? { dictationMode: params.dictationMode } : {}) } this.store.updateSettings({ voice: nextVoice }, { notifyListeners: true }) return this.listMobileSpeechModels() } async startMobileDictation(params: { dictationId: string modelId?: string clientId?: string connectionId?: string }): Promise<{ dictationId: string modelId: string }> { if (!this.store) { throw new Error('voice_dictation_unavailable') } const voice = this.store.getSettings().voice ?? getDefaultVoiceSettings() if (!voice.enabled) { throw new Error('voice_dictation_disabled') } const modelId = params.modelId || voice.sttModel if (!modelId) { throw new Error('voice_model_not_selected') } const modelState = await getSpeechModelManager(this.store).getModelState(modelId) if (modelState.status !== 'ready') { throw new Error(`voice_model_not_ready:${modelState.status}`) } if (!params.clientId) { throw new Error('dictation_requires_mobile_client') } if (this.mobileDictation) { throw new Error('dictation_already_active') } const owner = `mobile:${params.dictationId}` this.mobileDictation = { id: params.dictationId, owner, clientId: params.clientId, connectionId: params.connectionId, state: 'starting', partialText: '', finalTexts: [], errors: [] } try { await getSpeechSttService(this.store).startDictation( modelId, (event) => { const session = this.mobileDictation if (!session || session.id !== params.dictationId) { return } if (event.type === 'partial') { session.partialText = event.text ?? '' } else if (event.type === 'final') { const text = event.text?.trim() if (text) { session.finalTexts.push(text) session.partialText = '' } } else if (event.type === 'error') { session.errors.push(event.error ?? 'Speech worker error') } }, undefined, owner ) if (this.mobileDictation?.id !== params.dictationId) { throw new Error('dictation_canceled') } this.mobileDictation.state = 'active' } catch (error) { if (this.mobileDictation?.id === params.dictationId) { this.mobileDictation = null } throw error } return { dictationId: params.dictationId, modelId } } feedMobileDictation(params: { dictationId: string audioBase64: string sampleRate: number clientId?: string connectionId?: string }): { dictationId: string } { const session = this.mobileDictation if (!session || session.id !== params.dictationId) { throw new Error('dictation_stream_not_started') } if (!params.clientId || session.clientId !== params.clientId) { throw new Error('dictation_owner_mismatch') } if (session.connectionId && session.connectionId !== params.connectionId) { throw new Error('dictation_owner_mismatch') } if (session.state !== 'active') { throw new Error('dictation_stream_closing') } if (session.errors.length > 0) { throw new Error(session.errors[0]) } const pcm = Buffer.from(params.audioBase64, 'base64') const samples = new Float32Array(Math.floor(pcm.length / 2)) for (let i = 0; i < samples.length; i += 1) { samples[i] = pcm.readInt16LE(i * 2) / 32768 } getSpeechSttService(this.store!).feedAudio(samples, params.sampleRate, session.owner) return { dictationId: params.dictationId } } async finishMobileDictation(params: { dictationId: string clientId?: string connectionId?: string }): Promise<{ dictationId: string text: string }> { const session = this.mobileDictation if (!session || session.id !== params.dictationId) { throw new Error('dictation_stream_not_started') } if (!params.clientId || session.clientId !== params.clientId) { throw new Error('dictation_owner_mismatch') } if (session.connectionId && session.connectionId !== params.connectionId) { throw new Error('dictation_owner_mismatch') } session.state = 'closing' try { await getSpeechSttService(this.store!).stopDictation(session.owner) if (session.errors.length > 0) { throw new Error(session.errors[0]) } const text = [...session.finalTexts, session.partialText].join(' ').trim() return { dictationId: params.dictationId, text } } finally { if (this.mobileDictation?.id === session.id) { this.mobileDictation = null } } } async cancelMobileDictation(params: { dictationId: string clientId?: string connectionId?: string }): Promise<{ dictationId: string }> { const session = this.mobileDictation if ( session?.id === params.dictationId && params.clientId && session.clientId === params.clientId && (!session.connectionId || session.connectionId === params.connectionId) ) { session.state = 'closing' try { await getSpeechSttService(this.store!).stopDictation(session.owner) } finally { if (this.mobileDictation?.id === session.id) { this.mobileDictation = null } } } return { dictationId: params.dictationId } } private cancelMobileDictationSession(session: NonNullable): void { if (session.state === 'closing') { return } session.state = 'closing' void getSpeechSttService(this.store!) .stopDictation(session.owner) .finally(() => { if (this.mobileDictation?.id === session.id) { this.mobileDictation = null } }) } cancelMobileDictationForConnection(connectionId: string): void { const session = this.mobileDictation if (!session || session.connectionId !== connectionId) { return } this.cancelMobileDictationSession(session) } private cancelMobileDictationForClient(clientId: string): void { const session = this.mobileDictation if (!session || session.clientId !== clientId) { return } this.cancelMobileDictationSession(session) } private requireAccountServices(): RuntimeAccountServices { if (!this.accountServices) { throw new Error('Account services are not configured on this runtime') } return this.accountServices } getAccountsSnapshot(): AccountsSnapshot { const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices() return { claude: claudeAccounts.listAccounts(), codex: codexAccounts.listAccounts(), rateLimits: rateLimits.getState() } } // Why: RateLimitService polls only when the Electron window is visible AND // focused, and the inactive-account caches fill lazily when the user opens // the desktop AccountsPane. Mobile has neither trigger, so without this the // phone shows 0% / "—" against a backgrounded desktop. Errors swallowed // because partial usage is still useful for the rest of the snapshot. async refreshAccountsForMobile(): Promise { const { rateLimits } = this.requireAccountServices() await Promise.allSettled([ rateLimits.refresh(), rateLimits.fetchInactiveClaudeAccountsOnOpen(), rateLimits.fetchInactiveCodexAccountsOnOpen() ]) } // Why: connection migration replays subscriptions; use the stale-aware lane // so a reconnect cannot turn one mobile viewer into continuous forced fetches. async refreshAccountsForMobileSubscriber(): Promise { const { rateLimits } = this.requireAccountServices() await Promise.allSettled([ rateLimits.refreshIfStale(), rateLimits.fetchInactiveClaudeAccountsOnOpen(), rateLimits.fetchInactiveCodexAccountsOnOpen() ]) } selectClaudeAccount(accountId: string | null): Promise { return this.requireAccountServices().claudeAccounts.selectAccount(accountId) } selectCodexAccount(accountId: string | null): Promise { return this.requireAccountServices().codexAccounts.selectAccount(accountId) } selectCodexAccountForTarget( accountId: string | null, target: CodexAccountSelectionTarget ): Promise { return this.requireAccountServices().codexAccounts.selectAccountForTarget(accountId, target) } async consumeCodexRateLimitResetCredit( idempotencyKey: string, expectedScope: CodexResetCreditExpectedScope ): Promise { const { claudeAccounts, codexAccounts } = this.requireAccountServices() const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope) // Why: Codex selection and usage were captured before its mutation queue // advanced. Re-reading them here could pair scope A with queued selection B. const snapshot = { claude: claudeAccounts.listAccounts(), codex: result.codex, rateLimits: result.rateLimits } if ('status' in result) { return { status: result.status, retryDisposition: result.retryDisposition, reason: result.reason, scope: result.scope, snapshot } } return { outcome: result.outcome, scope: result.scope, snapshot } } removeClaudeAccount(accountId: string): Promise { return this.requireAccountServices().claudeAccounts.removeAccount(accountId) } // Why: register a managed Claude account from a CLAUDE_CONFIG_DIR the caller // already logged into. Lets the `orca account add` CLI drive `claude login` in // the user's terminal on a headless host, then capture the credentials here — // the desktop GUI's interactive add flow is unreachable over a remote runtime. addClaudeAccountFromConfigDir( configDir: string, options?: { runtime?: 'host' | 'wsl' wslDistro?: string | null previousLegacyCredentialsSha256?: string | null } ): Promise { return this.requireAccountServices().claudeAccounts.addAccountFromConfigDir(configDir, options) } removeCodexAccount(accountId: string): Promise { return this.requireAccountServices().codexAccounts.removeAccount(accountId) } // Why: Codex counterpart of addClaudeAccountFromConfigDir — register a managed // Codex account from a CODEX_HOME the caller already logged into, so headless // hosts can add accounts via `orca account add --agent codex`. addCodexAccountFromHome( sourceHome: string, target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } ): Promise { return this.requireAccountServices().codexAccounts.addAccountFromHome(sourceHome, target) } // Why: rate-limit polling fires every 5 minutes and on account switch. // Mobile clients subscribe to receive a fresh AccountsSnapshot whenever // RateLimitService pushes new usage data, mirroring the existing // `rateLimits:update` IPC channel desktop already uses. onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { const services = this.requireAccountServices() return services.rateLimits.onStateChange((rateLimits) => { listener({ claude: services.claudeAccounts.listAccounts(), codex: services.codexAccounts.listAccounts(), rateLimits }) }) } // ─── Mobile Fit Override Management ───────────────────────── // Why: legacy mobile RPC entrypoint. After the state-machine rewrite this // is a thin shim that computes a `PtyLayoutTarget` and routes through // `enqueueLayout`. Keeps the same observable return shape so older mobile // builds continue to work. See docs/mobile-terminal-layout-state-machine.md. async resizeForClient( ptyId: string, mode: 'mobile-fit' | 'restore', clientId: string, cols?: number, rows?: number ): Promise<{ cols: number rows: number previousCols: number | null previousRows: number | null mode: 'mobile-fit' | 'desktop-fit' }> { if (mode === 'mobile-fit') { if (cols == null || rows == null || !Number.isFinite(cols) || !Number.isFinite(rows)) { throw new Error('invalid_dimensions') } const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport(cols, rows) const currentSize = this.getTerminalSize(ptyId) const existing = this.terminalFitOverrides.get(ptyId) // Capture baseline cols/rows for the return value (existing override's // baseline wins over current size to preserve original desktop dims // across multiple re-fits). const previousCols = existing?.previousCols ?? currentSize?.cols ?? null const previousRows = existing?.previousRows ?? currentSize?.rows ?? null // Why: legacy resizeForClient callers bypass handleMobileSubscribe, so // mobileSubscribers stays empty and resolveDesktopRestoreTarget's step-1 // (per-subscriber baseline) never matches. Stash the pre-fit PTY size // into lastRendererSizes so restore lands on step 2 (renderer geometry) // instead of step 3 (current phone-fit dims = no-op restore). if (currentSize && !existing) { this.lastRendererSizes.set(ptyId, { cols: currentSize.cols, rows: currentSize.rows }) } this.freshSubscribeGuard.add(ptyId) let result: ApplyLayoutResult try { result = await this.enqueueLayout(ptyId, { kind: 'phone', cols: clampedCols, rows: clampedRows, ownerClientId: clientId }) } finally { this.freshSubscribeGuard.delete(ptyId) } if (!result.ok) { throw new Error('resize_failed') } // Why: mobile-fit via resizeForClient is a deliberate mobile action; // the actor takes the floor (updates lastActedAt; mode-flip case is // already handled by enqueueLayout above). await this.mobileTookFloor(ptyId, clientId) return { cols: clampedCols, rows: clampedRows, previousCols, previousRows, mode: 'mobile-fit' } } // restore mode const override = this.terminalFitOverrides.get(ptyId) if (!override) { throw new Error('no_active_override') } // Only the owning client can restore — prevents one phone from undoing // another phone's active fit. if (override.clientId !== clientId) { throw new Error('not_override_owner') } const restore = this.resolveDesktopRestoreTarget(ptyId) const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols: restore.cols, rows: restore.rows }) if (!result.ok) { throw new Error('resize_failed') } // Why: legacy mobile clients on the resizeForClient path also need a // fit-override-listener notification (the renderer-side terminalFitOverrideChanged // is already emitted by applyLayout's mode-flip path). this.notifyFitOverrideListeners(ptyId, 'desktop-fit', restore.cols, restore.rows) return { cols: restore.cols, rows: restore.rows, previousCols: null, previousRows: null, mode: 'desktop-fit' } } getTerminalFitOverride(ptyId: string) { return this.terminalFitOverrides.get(ptyId) ?? null } getAllTerminalFitOverrides(): Map< string, { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } > { const result = new Map< string, { mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number } >() for (const [ptyId, override] of this.terminalFitOverrides) { result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows }) } for (const [ptyId] of this.remoteDesktopOwners) { if (result.has(ptyId)) { continue } const size = this.getTerminalSize(ptyId) if (size) { result.set(ptyId, { mode: 'remote-desktop-fit', ...size }) } } return result } getAllTerminalDrivers(): Map { return new Map(this.currentDriver) } getAllBrowserDrivers(): Map { return new Map(this.currentBrowserDriver) } private getBrowserDriver(browserPageId: string): RuntimeBrowserDriverState { return this.currentBrowserDriver.get(browserPageId) ?? { kind: 'idle' } } private setBrowserDriver(browserPageId: string, next: RuntimeBrowserDriverState): void { const prev = this.getBrowserDriver(browserPageId) if (prev.kind === next.kind) { if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { return } if (prev.kind !== 'mobile' && next.kind !== 'mobile') { return } } if (next.kind === 'idle') { this.currentBrowserDriver.delete(browserPageId) } else { this.currentBrowserDriver.set(browserPageId, next) } this.notifier?.browserDriverChanged?.(browserPageId, next) } reclaimBrowserForDesktop(browserPageId: string): boolean { this.setBrowserDriver(browserPageId, { kind: 'desktop' }) this.activeBrowserScreencastsByPage.get(browserPageId)?.cancel(true) return true } onClientDisconnected(clientId: string): void { this.revokeTerminalFileGrantsForClient(clientId) this.cancelMobileDictationForClient(clientId) // (1) Cancel pending restore-debounce timers owned by this client. for (const [ptyId, entry] of this.pendingRestoreTimers) { if (entry.clientId === clientId) { clearTimeout(entry.timer) this.pendingRestoreTimers.delete(ptyId) } } // (2) Promote any soft-leave grace owned by this client into immediate // finalization. Grace existed to absorb a quick re-subscribe; a real // disconnect kills any chance of re-subscribe. // // Note: this is mode-decoupled (matches docs/mobile-terminal-layout-state-machine.md // sub-case 2). Today's pre-rewrite code only restored when // `mode === 'auto' && wasResizedToPhone`; the new design restores // whenever the layout is currently `phone`. This is an intentional // behavior fix — `mode === 'phone'` with no subscribers is a degenerate // state nothing in product depends on. for (const [ptyId, soft] of this.pendingSoftLeavers) { if (soft.clientId !== clientId) { continue } clearTimeout(soft.timer) this.pendingSoftLeavers.delete(ptyId) // Cancel any in-flight 300ms restore timer too — we'll handle it inline. const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { clearTimeout(pending.timer) this.pendingRestoreTimers.delete(ptyId) } const cur = this.layouts.get(ptyId) // Why: Indefinite hold (mobileAutoRestoreFitMs == null) keeps the PTY // at phone dims after the phone disconnects; the desktop banner's // Restore button is the explicit return path. See // docs/mobile-fit-hold.md. if (this.hasRemoteDesktopViewers(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) void this.applyRemoteDesktopLayout(ptyId) continue } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) void this.applyRemoteDesktopLayout(ptyId) continue } // Use the soft-leaver's snapshot baseline as a hint, falling // through to resolveDesktopRestoreTarget for missing values. const fallback = this.resolveDesktopRestoreTarget(ptyId) const cols = soft.record.previousCols ?? fallback.cols const rows = soft.record.previousRows ?? fallback.rows void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } this.setDriver(ptyId, { kind: 'idle' }) } // (3) Immediate restore for PTYs where this client was the last // mobile subscriber. With multi-mobile, peer subscribers keep the // floor; only when the inner map empties do we transition to desktop. const ptysWithSurvivingPeers: string[] = [] const ptysToRestore: { ptyId: string; baseline: { cols: number; rows: number } | null }[] = [] for (const [ptyId, inner] of this.mobileSubscribers) { const subscriber = inner.get(clientId) if (!subscriber) { continue } // Snapshot baseline before deleting — needed once mobileSubscribers // entry is gone for the resolveDesktopRestoreTarget chain. const baseline = subscriber.previousCols != null && subscriber.previousRows != null ? { cols: subscriber.previousCols, rows: subscriber.previousRows } : null inner.delete(clientId) this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { ptysWithSurvivingPeers.push(ptyId) } else { this.mobileSubscribers.delete(ptyId) ptysToRestore.push({ ptyId, baseline }) } } for (const { ptyId, baseline } of ptysToRestore) { const cur = this.layouts.get(ptyId) // Why: Indefinite hold gate — see soft-leaver branch above. if (this.hasRemoteDesktopViewers(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) void this.applyRemoteDesktopLayout(ptyId) continue } else if (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) { if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) void this.applyRemoteDesktopLayout(ptyId) continue } const fallback = this.resolveDesktopRestoreTarget(ptyId) const cols = baseline?.cols ?? fallback.cols const rows = baseline?.rows ?? fallback.rows void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } this.setDriver(ptyId, { kind: 'idle' }) } // (4) Driver re-election where peers survived. If the disconnecting // client was the active driver, the most-recent surviving actor takes // the floor. for (const ptyId of ptysWithSurvivingPeers) { const driver = this.getDriver(ptyId) if (driver.kind !== 'mobile' || driver.clientId !== clientId) { continue } const inner = this.mobileSubscribers.get(ptyId) const next = inner ? this.pickMostRecentActor(inner) : null if (!next) { continue } this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) const mode = this.getMobileDisplayMode(ptyId) if (mode === 'desktop') { continue } const nextSub = inner!.get(next.clientId) const nextViewport = nextSub?.viewport if (!nextViewport) { continue } void this.enqueueLayout(ptyId, { kind: 'phone', cols: nextViewport.cols, rows: nextViewport.rows, ownerClientId: next.clientId }) } // (5) Legacy-callers fallback. Older mobile builds use resizeForClient // directly and never populate mobileSubscribers. For those PTYs the // override carries the owning clientId; restore the layout when the // owner disconnects. resolveDesktopRestoreTarget reads lastRendererSizes // (which the legacy mobile-fit branch stashes the pre-fit size into). for (const [ptyId, override] of this.terminalFitOverrides) { if (override.clientId !== clientId) { continue } if (this.mobileSubscribers.has(ptyId)) { continue } const cur = this.layouts.get(ptyId) if (cur?.kind !== 'phone') { continue } // Why: Indefinite hold gate — see soft-leaver branch above. Legacy // mobile clients (resizeForClient path) honor the same setting. if (this.getAutoRestoreFitMs() == null) { continue } const fallback = this.resolveDesktopRestoreTarget(ptyId) const cols = override.previousCols ?? fallback.cols const rows = override.previousRows ?? fallback.rows void this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) } } onPtyExit(ptyId: string, exitCode: number, exitIncarnationId?: PtyIncarnationId): void { const pty = this.ptysById.get(ptyId) if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { return } const preservesAbnormalSshSurface = this.isSshOwnedPtyId(ptyId) && pty?.connectionId != null && exitCode < 0 if (preservesAbnormalSshSurface) { this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) } else { this.retirePtyAgentLaunchAuthority(ptyId) } const incarnationId = exitIncarnationId ?? pty?.incarnationId ?? `runtime:${this.runtimeId}:${this.getPtyLifecycleGeneration(ptyId)}` this.advancePtyLifecycleGeneration(ptyId) const exactSurfaceByKey = new Map< string, Pick >() for (const leaf of this.getLeavesForPty(ptyId)) { exactSurfaceByKey.set(`${leaf.worktreeId}\0${leaf.tabId}\0${leaf.leafId}`, { worktreeId: leaf.worktreeId, parentTabId: leaf.tabId, leafId: leaf.leafId }) } const parsedPaneKey = parsePaneKey(pty?.paneKey ?? '') if (pty?.tabId && parsedPaneKey) { exactSurfaceByKey.set(`${pty.worktreeId}\0${pty.tabId}\0${parsedPaneKey.leafId}`, { worktreeId: pty.worktreeId, parentTabId: pty.tabId, leafId: parsedPaneKey.leafId }) } const exactSurfaces = [...exactSurfaceByKey.values()] const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) const exitMatchesPendingRegistration = this.pendingPtyRegistrationIncarnations.has(ptyId) && (pendingIncarnation === null || exitIncarnationId === null || exitIncarnationId === undefined || pendingIncarnation === exitIncarnationId) if (exitMatchesPendingRegistration) { // Why: reused surfaces can look registered while their replacement incarnation still awaits admission. this.earlyExitedPtyIncarnations.set( ptyId, exitIncarnationId ?? pendingIncarnation ?? pty?.incarnationId ?? null ) } const intentionalStopIncarnation = this.intentionalHandlelessPtyStops.get(ptyId) const preservesIntentionalHandlelessSurface = this.intentionalHandlelessPtyStops.has(ptyId) && (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) this.rawTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) this.setupCompletionTokenByPtyId.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.providerSequenceInitializedPtys.delete(ptyId) this.providerSequenceOffsetByPtyId.delete(ptyId) this.providerSnapshotPreferredPtys.delete(ptyId) this.providerModeTrackersByPtyId.delete(ptyId) this.providerModeSnapshotScansByPtyId.delete(ptyId) this.providerBufferAcquisitionsByPtyId.delete(ptyId) this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. // Previously only explicit closeTerminal evicted it, so natural exits leaked // one team per never-reused teamId for the runtime's lifetime. const exitedTeamLeaderHandle = this.handleByPtyId.get(ptyId) if (exitedTeamLeaderHandle) { this.claudeAgentTeams.removeTeamForLeaderHandle(exitedTeamLeaderHandle) } // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and // short-circuits with `pty-exited`. this.layouts.delete(ptyId) this.layoutQueues.delete(ptyId) this.freshSubscribeGuard.delete(ptyId) const pendingRestore = this.pendingRestoreTimers.get(ptyId) if (pendingRestore) { clearTimeout(pendingRestore.timer) this.pendingRestoreTimers.delete(ptyId) } const pendingSoft = this.pendingSoftLeavers.get(ptyId) if (pendingSoft) { clearTimeout(pendingSoft.timer) this.pendingSoftLeavers.delete(ptyId) } // Why: a cold restore can respawn under the same session id within the // delayed-Enter window; the armed Enter would inject \r into the // replacement and stamp rows it never received. this.retirePendingMessageDeliveryForPty(ptyId) if (this.terminalFitOverrides.has(ptyId)) { this.terminalFitOverrides.delete(ptyId) this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) } // Why: clear driver state and notify the renderer so any lock banner on // this dead pane unmounts. Without this, the pane shows a stuck banner // until tab teardown, and `getDriver(deadPtyId)` would keep returning a // stale `mobile{X}` to any caller that hasn't yet seen the exit IPC. if (this.currentDriver.has(ptyId)) { this.currentDriver.delete(ptyId) this.notifier?.terminalDriverChanged(ptyId, { kind: 'idle' }) } this.remoteDesktopViewers.delete(ptyId) this.remoteDesktopOwners.delete(ptyId) this.remoteDesktopHostReclaimTargets.delete(ptyId) this.remoteDesktopViewerRevisions.delete(ptyId) this.disposeHeadlessTerminal(ptyId) this.agentDetector?.onExit(ptyId) if (pty) { pty.connected = false pty.runtimeSessionOwned = false this.setPairedRendererSessionOwnership(pty.ptyId, false) pty.disconnectedAt = Date.now() pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } if (preservesIntentionalHandlelessSurface || preservesAbnormalSshSurface) { // Why: relay loss is recoverable; keep the HUB-owned pane addressable through the bounded reconnect grace. this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } else { // Why: permanent process exit is absence, not a starting/sleeping tab. // Retire before publishing so paired clients never persist a ghost. this.retireMobileSessionSurfacesForPty(ptyId, incarnationId, exactSurfaces) } for (const leaf of this.getLeavesForPty(ptyId)) { this.detachedPreAllocatedLeaves.delete(ptyId) leaf.connected = false leaf.writable = false leaf.lastExitCode = exitCode this.resolveExitWaiters(leaf) if (!preservesAbnormalSshSurface) { this.failActiveDispatchOnExit(leaf, exitCode) } } this.pruneDisconnectedPtyRecords() } // ─── Driver state (mobile-presence lock) ────────────────────────── // // See docs/mobile-presence-lock.md. getDriver(ptyId: string): DriverState { return this.currentDriver.get(ptyId) ?? { kind: 'idle' } } private setDriver(ptyId: string, next: DriverState): void { const prev = this.getDriver(ptyId) if (prev.kind === next.kind) { if (prev.kind === 'mobile' && next.kind === 'mobile' && prev.clientId === next.clientId) { return } if (prev.kind !== 'mobile' && next.kind !== 'mobile') { return } } if (next.kind === 'idle') { this.currentDriver.delete(ptyId) } else { this.currentDriver.set(ptyId, next) } this.notifier?.terminalDriverChanged(ptyId, next) const listeners = this.driverListeners.get(ptyId) if (listeners) { notifyRuntimeListeners(listeners, (listener) => listener(next), 'pty-driver') } } // Why: the host's own fit cascade (window resize, split drag, tab reveal, // "+"-new-tab re-render) must not resize a PTY whose width a remote client // owns — that is the remote "porridge" bug. True while a phone (mobile driver) // OR an active remote desktop viewer owns the PTY. Input is deliberately NOT gated // here (see the `writePtyInput` mobile-only checks): shared-control desktop // viewers may still type alongside the host. // Note: this is intentionally NOT a driver kind. An active remote viewer needs // only resize suppression, not the mobile driver machinery (input lock, // phone-fit, driver-change banners), so it lives in its own registry and does // not perturb the presence-lock state machine. It also coexists with mobile: // while a phone drives, the registry still suppresses host resize, and when // the phone leaves the surviving viewer keeps the PTY suppressed. isPtyResizeDrivenRemotely(ptyId: string): boolean { if (this.getDriver(ptyId).kind === 'mobile') { return true } return this.isRemoteDesktopResizeDriven(ptyId) } isRemoteDesktopResizeDriven(ptyId: string): boolean { return this.remoteDesktopOwners.has(ptyId) } isRemoteDesktopViewerOwner(ptyId: string, subscriptionKey: string): boolean { return this.remoteDesktopOwners.get(ptyId) === subscriptionKey } getRemoteDesktopFitHold( ptyId: string, subscriptionKey: string ): { mode: 'remote-desktop-fit' | 'desktop-fit'; cols: number; rows: number } { const size = this.getTerminalSize(ptyId) ?? { cols: 0, rows: 0 } return { mode: this.isRemoteDesktopViewerOwner(ptyId, subscriptionKey) ? 'desktop-fit' : 'remote-desktop-fit', ...size } } private hasRemoteDesktopViewers(ptyId: string): boolean { const viewers = this.remoteDesktopViewers.get(ptyId) return viewers !== undefined && viewers.size > 0 } private activeRemoteDesktopViewport(ptyId: string): { cols: number; rows: number } | null { const owner = this.remoteDesktopOwners.get(ptyId) return owner ? (this.remoteDesktopViewers.get(ptyId)?.get(owner) ?? null) : null } private resolveRemoteDesktopHostReclaimTarget(ptyId: string): { cols: number; rows: number } { const target = this.remoteDesktopHostReclaimTargets.get(ptyId) if (target) { return target } // Why: a viewer can join while a phone owns the actual PTY size. The // mobile restore chain retains the pre-phone desktop geometry; current // PTY size alone would incorrectly capture the phone grid as host truth. return this.resolveDesktopRestoreTarget(ptyId) } private ensureRemoteDesktopHostReclaimTarget(ptyId: string): void { if (!this.remoteDesktopHostReclaimTargets.has(ptyId)) { this.remoteDesktopHostReclaimTargets.set( ptyId, this.resolveRemoteDesktopHostReclaimTarget(ptyId) ) } } recordRemoteDesktopHostReclaimTarget(ptyId: string, cols: number, rows: number): void { // Why: phone presence also suppresses host resize, but must not seed the // separate remote-viewer cache when no desktop stream owns a width floor. if (!this.remoteDesktopOwners.has(ptyId) || cols <= 0 || rows <= 0) { return } this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) } private hasRemoteDesktopLayoutState(ptyId: string): boolean { return this.remoteDesktopOwners.has(ptyId) || this.remoteDesktopHostReclaimTargets.has(ptyId) } private bumpRemoteDesktopViewerRevision(ptyId: string): number { const revision = (this.remoteDesktopViewerRevisions.get(ptyId) ?? 0) + 1 this.remoteDesktopViewerRevisions.set(ptyId, revision) return revision } async applyRemoteDesktopLayout(ptyId: string): Promise { if (this.getDriver(ptyId).kind === 'mobile') { return true } const target = this.activeRemoteDesktopViewport(ptyId) const reclaimingHost = !target const viewerRevision = this.remoteDesktopViewerRevisions.get(ptyId) ?? 0 const layoutTarget: PtyLayoutTarget = target ? { kind: 'remote-desktop', cols: target.cols, rows: target.rows, ownerSubscriptionKey: this.remoteDesktopOwners.get(ptyId)! } : { kind: 'desktop', ...this.resolveRemoteDesktopHostReclaimTarget(ptyId) } this.freshSubscribeGuard.add(ptyId) try { const result = await this.enqueueLayout(ptyId, layoutTarget) // Why: only drop the recorded host size once the reclaim resize actually // landed. If it failed, the PTY is still at the remote-viewer width, so // keep the target for the next reclaim (otherwise it resolves via the // stale remote width and never restores true host geometry). if ( reclaimingHost && result.ok && !this.remoteDesktopOwners.has(ptyId) && this.remoteDesktopViewerRevisions.get(ptyId) === viewerRevision ) { this.remoteDesktopHostReclaimTargets.delete(ptyId) } return result.ok } finally { this.freshSubscribeGuard.delete(ptyId) } } // Why: attachment only records geometry. Passive hydration/reconnect must not // steal the shared PTY from the desktop where the user is actively working. async updateRemoteDesktopViewer( ptyId: string, subscriptionKey: string, clientId: string, cols: number, rows: number, claim = true ): Promise { const viewport = clampTerminalViewport(cols, rows) if (claim) { this.ensureRemoteDesktopHostReclaimTarget(ptyId) } let viewers = this.remoteDesktopViewers.get(ptyId) if (!viewers) { viewers = new Map< string, { clientId: string; cols: number; rows: number; activity: number } >() this.remoteDesktopViewers.set(ptyId, viewers) } const prior = viewers.get(subscriptionKey) if ( prior && prior.cols === viewport.cols && prior.rows === viewport.rows && (!claim || this.remoteDesktopOwners.get(ptyId) === subscriptionKey) ) { if (claim && this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { const size = this.getTerminalSize(ptyId) if (size?.cols !== viewport.cols || size.rows !== viewport.rows) { return this.applyRemoteDesktopLayout(ptyId) } } return true } const activity = claim ? ++this.remoteDesktopActivity : (prior?.activity ?? 0) viewers.set(subscriptionKey, { clientId, cols: viewport.cols, rows: viewport.rows, activity }) this.bumpRemoteDesktopViewerRevision(ptyId) if (claim) { this.remoteDesktopOwners.set(ptyId, subscriptionKey) return this.applyRemoteDesktopLayout(ptyId) } return true } claimRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { const viewer = this.remoteDesktopViewers.get(ptyId)?.get(subscriptionKey) if (!viewer) { return Promise.resolve(false) } if (this.remoteDesktopOwners.get(ptyId) === subscriptionKey) { const size = this.getTerminalSize(ptyId) return size?.cols === viewer.cols && size.rows === viewer.rows ? Promise.resolve(true) : this.applyRemoteDesktopLayout(ptyId) } this.ensureRemoteDesktopHostReclaimTarget(ptyId) viewer.activity = ++this.remoteDesktopActivity this.remoteDesktopOwners.set(ptyId, subscriptionKey) this.bumpRemoteDesktopViewerRevision(ptyId) return this.applyRemoteDesktopLayout(ptyId) } claimRemoteDesktopHost(ptyId: string, cols: number, rows: number): Promise { if (!this.remoteDesktopOwners.has(ptyId)) { // Why: disconnect can remove the owner before its queued host resize // lands. A host input in that window must join the reclaim, not pass it. return this.remoteDesktopHostReclaimTargets.has(ptyId) ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) } const viewport = clampTerminalViewport(cols, rows) this.remoteDesktopHostReclaimTargets.set(ptyId, viewport) this.remoteDesktopOwners.delete(ptyId) this.bumpRemoteDesktopViewerRevision(ptyId) return this.applyRemoteDesktopLayout(ptyId) } unregisterRemoteDesktopViewer(ptyId: string, subscriptionKey: string): Promise { return this.unregisterRemoteDesktopViewers(ptyId, [subscriptionKey]) } unregisterRemoteDesktopViewers( ptyId: string, subscriptionKeys: Iterable ): Promise { const viewers = this.remoteDesktopViewers.get(ptyId) if (!viewers) { return Promise.resolve(false) } let changed = false let removedOwner = false for (const subscriptionKey of subscriptionKeys) { removedOwner = this.remoteDesktopOwners.get(ptyId) === subscriptionKey || removedOwner changed = viewers.delete(subscriptionKey) || changed } if (!changed) { return Promise.resolve(false) } if (viewers.size === 0) { this.remoteDesktopViewers.delete(ptyId) } if (removedOwner) { let fallback: { key: string; activity: number } | null = null for (const [key, viewer] of viewers) { if (viewer.activity > 0 && (!fallback || viewer.activity > fallback.activity)) { fallback = { key, activity: viewer.activity } } } if (fallback) { this.remoteDesktopOwners.set(ptyId, fallback.key) } else { this.remoteDesktopOwners.delete(ptyId) } } this.bumpRemoteDesktopViewerRevision(ptyId) return removedOwner ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) } // Why: the one-shot `terminal.updateViewport` RPC has no disconnect hook, so // it must never *create* a width floor (that floor would leak — nothing // releases it, pinning the host at a stale width after the viewer is gone). // It only refreshes the floor(s) this client already owns via its stream // subscription, keyed by clientId. Mirrors the mobile `updateMobileViewport` // no-op-without-subscription invariant. Returns false when the client owns no // floor (passive/stream-less viewer) — a stream-less viewer must not lock host // resize. refreshRemoteDesktopViewer( ptyId: string, clientId: string, cols: number, rows: number, claim = false ): Promise { const viewers = this.remoteDesktopViewers.get(ptyId) if (!viewers) { return Promise.resolve(false) } const viewport = clampTerminalViewport(cols, rows) if (claim) { // Why: terminal.send may be the first activity while the stream is only // passively registered. Snapshot host truth before this refresh owns it. this.ensureRemoteDesktopHostReclaimTarget(ptyId) } let changed = false for (const [subscriptionKey, viewer] of viewers) { if (viewer.clientId === clientId) { const activity = claim ? ++this.remoteDesktopActivity : viewer.activity viewers.set(subscriptionKey, { ...viewer, cols: viewport.cols, rows: viewport.rows, activity }) if (claim) { this.remoteDesktopOwners.set(ptyId, subscriptionKey) } changed = true } } if (!changed) { return Promise.resolve(false) } this.bumpRemoteDesktopViewerRevision(ptyId) return this.remoteDesktopOwners.has(ptyId) ? this.applyRemoteDesktopLayout(ptyId) : Promise.resolve(true) } async updateDesktopViewport( ptyId: string, viewport: { cols: number; rows: number } ): Promise { const { cols, rows } = clampTerminalViewport(viewport.cols, viewport.rows) if (this.terminalFitOverrides.has(ptyId) || this.getDriver(ptyId).kind === 'mobile') { this.recordRendererGeometry(ptyId, cols, rows) return true } if (this.isResizeSuppressed()) { return false } this.freshSubscribeGuard.add(ptyId) try { const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) if (result.ok) { this.refreshRendererGeometry(ptyId, cols, rows) } return result.ok } finally { this.freshSubscribeGuard.delete(ptyId) } } markMobileActor(ptyId: string, clientId: string): void { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) if (sub) { sub.lastActedAt = Date.now() } this.setDriver(ptyId, { kind: 'mobile', clientId }) } beginMobileInputFloor( ptyId: string, clientId: string ): { commit: () => Promise; rollback: () => void } | null { // Why: admit a client still inside its soft-leave grace (mirrors // mobileTookFloor) so a write landing in that window reserves the floor // instead of being dropped; post-grace/orphaned writers stay rejected. const softLeaver = this.pendingSoftLeavers.get(ptyId) if (!this.mobileSubscribers.get(ptyId)?.has(clientId) && softLeaver?.clientId !== clientId) { return null } const state = this.mobileInputFloorClaims.get(ptyId) ?? { base: this.getDriver(ptyId), generation: 0, committedGeneration: 0, pending: new Map() } this.mobileInputFloorClaims.set(ptyId, state) const token = Symbol('mobile-input-floor') const generation = ++state.generation state.pending.set(token, { clientId, generation }) this.setDriver(ptyId, { kind: 'mobile', clientId }) let settled = false return { commit: async () => { if (settled) { return } settled = true state.pending.delete(token) // Why: a newer accepted write owns the floor; an older claim that was // delayed before commit must not replace its rollback baseline or driver. if (generation < state.committedGeneration) { if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { this.mobileInputFloorClaims.delete(ptyId) } return } const previousFloor = state.base // Why: a successful write becomes the rollback baseline for any // overlapping reservations that have not reached the PTY yet. state.committedGeneration = generation state.base = { kind: 'mobile', clientId } await this.mobileTookFloor( ptyId, clientId, previousFloor, () => this.mobileInputFloorClaims.get(ptyId) === state && state.committedGeneration === generation ) if (state.pending.size === 0 && this.mobileInputFloorClaims.get(ptyId) === state) { this.mobileInputFloorClaims.delete(ptyId) } }, rollback: () => { if (settled) { return } settled = true state.pending.delete(token) if (this.mobileInputFloorClaims.get(ptyId) !== state) { return } const current = this.getDriver(ptyId) if (current.kind === 'mobile' && current.clientId === clientId) { const pendingClientId = Array.from(state.pending.values()).at(-1)?.clientId this.setDriver( ptyId, pendingClientId ? { kind: 'mobile', clientId: pendingClientId } : state.base ) } if (state.pending.size === 0) { this.mobileInputFloorClaims.delete(ptyId) } } } } // Why: invoked from mobile RPC method handlers (terminal.send / setDisplayMode / // resizeForClient / fresh subscribe with auto). Records the actor as the // most recent mobile driver and re-applies phone-fit if we were previously // in `desktop` mode (mobile reclaims a take-back). Mobile-to-mobile hand-offs // are no-ops for resize. async mobileTookFloor( ptyId: string, clientId: string, previousFloor?: DriverState, isCurrent: () => boolean = () => true ): Promise { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) const softLeaver = this.pendingSoftLeavers.get(ptyId) // Why: native chat pauses terminal output, so its later sends have no // subscriber lifecycle that could release a newly-created desktop lock. if (!sub && softLeaver?.clientId !== clientId) { return } if (sub) { sub.lastActedAt = Date.now() } const prev = previousFloor ?? this.getDriver(ptyId) const currentMode = this.mobileDisplayModes.get(ptyId) // Why: a deliberate mobile action implies mobile is resuming control. // If the display mode is currently 'desktop' (set by an earlier // take-back), flip it back to 'auto' (= map absence) and re-apply so // phone-fit takes hold again. See docs/mobile-presence-lock.md. if (prev.kind === 'desktop' || currentMode === 'desktop') { if (currentMode === 'desktop') { this.mobileDisplayModes.delete(ptyId) } await this.applyMobileDisplayMode(ptyId) } // Why: display changes are async; a later PTY write must keep the floor // when an older phone-fit operation eventually completes. if (!isCurrent()) { return } this.setDriver(ptyId, { kind: 'mobile', clientId }) } // Why: in-place viewport update on the existing mobile subscription — // used when the mobile keyboard opens/closes and shrinks/grows the // visible terminal area. We refresh the subscriber's viewport, re-fit // the PTY to the new dims, and emit a 'resized' event so the mobile // xterm reinits inline at the new dims without re-subscribing. This // avoids the unsubscribe → resubscribe cycle which would (a) flash the // desktop lock banner during the brief idle gap and (b) cause the new // subscribe to capture the already-phone-fitted PTY size as its // restore baseline (stuck-dim bug on later disconnect). // No-op when the client isn't actually subscribed to this PTY. async updateMobileViewport( ptyId: string, clientId: string, viewport: { cols: number; rows: number } ): Promise<{ updated: boolean; applied: boolean }> { const inner = this.mobileSubscribers.get(ptyId) const sub = inner?.get(clientId) if (!sub) { return { updated: false, applied: false } } sub.viewport = viewport sub.lastActedAt = Date.now() const mode = this.getMobileDisplayMode(ptyId) if (mode === 'desktop') { // Watching at desktop dims — viewport is informational only. return { updated: true, applied: false } } // Drive PTY dims by the most-recent-actor (just updated to this client). const winner = this.pickMostRecentActor(inner!) if (!winner) { return { updated: false, applied: false } } const winnerSub = inner!.get(winner.clientId) const driveViewport = winnerSub?.viewport ?? viewport const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( driveViewport.cols, driveViewport.rows ) sub.wasResizedToPhone = true // The driver is already mobile{this client} when we got here; refresh // to update lastActedAt-based ordering on later actor selection. this.setDriver(ptyId, { kind: 'mobile', clientId }) const needsFreshSubscribeGuard = !this.layouts.has(ptyId) if (needsFreshSubscribeGuard) { this.freshSubscribeGuard.add(ptyId) } let result: ApplyLayoutResult try { result = await this.enqueueLayout(ptyId, { kind: 'phone', cols: clampedCols, rows: clampedRows, ownerClientId: winner.clientId }) } finally { if (needsFreshSubscribeGuard) { this.freshSubscribeGuard.delete(ptyId) } } return { updated: true, applied: result.ok } } // Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take // back" / "Restore" button). Forces the PTY back to desktop dims and flips // the driver to `desktop`, suppressing further mobile-driven dim changes // until a mobile actor takes the floor again. Three cases, each ending in // releaseDesktopTakeBack: // 1. Active mobile subscriber: route through applyMobileDisplayMode so the // existing 'resized' event reaches the phone. // 2. Held override, no subscriber (post-indefinite-hold): resolve the // restore target and enqueueLayout directly. // 3. Stale mobile driver, no subscriber and no override: nothing to resize, // just drop the lock. See docs/mobile-fit-hold.md. // // Why: explicit desktop take-back is a user command to reclaim input control // NOW. Unlike the auto-restore timer and phone-initiated setDisplayMode paths // (which keep the lock when a resize can't converge, #7588), this gesture // ALWAYS drops the presence lock and banner. "Take back all terminals" // reclaims several PTYs at once; a background pane whose desktop resize can't // converge must not strand its banner on the other terminals. The resize is // best-effort — the desktop renderer refits the PTY on its next settled // frame. Returns `true` whenever there was a lock to reclaim, `false` only // when there was nothing to reclaim. async reclaimTerminalForDesktop(ptyId: string): Promise { if (this.isMobileSubscriberActive(ptyId)) { this.setMobileDisplayMode(ptyId, 'desktop') await this.applyMobileDisplayMode(ptyId) this.releaseDesktopTakeBack(ptyId) // Why: a desktop-initiated reclaim is "I'm taking over right now", not a // sticky preference. The next mobile subscribe (e.g. user switches back to // the terminal tab on the phone) must default to phone-fit again, not stay // in passive desktop-watch mode. this.setMobileDisplayMode(ptyId, 'auto') if (this.hasRemoteDesktopLayoutState(ptyId)) { return this.applyRemoteDesktopLayout(ptyId) } return true } const heldOverride = this.terminalFitOverrides.get(ptyId) if (heldOverride && this.hasRemoteDesktopLayoutState(ptyId)) { const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { clearTimeout(pending.timer) this.pendingRestoreTimers.delete(ptyId) } const softLeaver = this.pendingSoftLeavers.get(ptyId) if (softLeaver) { clearTimeout(softLeaver.timer) this.pendingSoftLeavers.delete(ptyId) } const priorDriver = this.getDriver(ptyId) this.setDriver(ptyId, { kind: 'idle' }) const converged = await this.applyRemoteDesktopLayout(ptyId) if (!converged) { this.setDriver(ptyId, priorDriver) return false } this.setDriver(ptyId, { kind: 'desktop' }) this.setMobileDisplayMode(ptyId, 'auto') return true } if (heldOverride) { const pending = this.pendingRestoreTimers.get(ptyId) if (pending) { clearTimeout(pending.timer) this.pendingRestoreTimers.delete(ptyId) } // Why: with no subscribers, resolveDesktopRestoreTarget can fall through // to current PTY size — which is at phone dims (wrong). Prefer a fresh // desktop renderer measurement when one exists; otherwise use the // override's pre-fit baseline before falling back to current size. const fallback = this.resolveDesktopRestoreTarget(ptyId) const renderer = this.lastRendererSizes.get(ptyId) const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) this.releaseDesktopTakeBack(ptyId) this.setMobileDisplayMode(ptyId, 'auto') return true } // Why: a stale lock — driver still reads mobile with no active subscriber // and no held override (e.g. reclaimed inside the soft-leave grace, or a // subscriber that dropped without a clean unsubscribe). Release it so the // banner can't linger; there is nothing to resize. if (this.getDriver(ptyId).kind === 'mobile') { this.releaseDesktopTakeBack(ptyId) return true } return false } // Why: the shared "banner must be gone now" step for an explicit desktop // take-back. Releases the presence lock (driver → desktop) and, if the // best-effort resize left a fit-override held (resize didn't converge), // clears it optimistically with a paired desktop-fit 0×0 — the same signal // onPtyExit emits — so neither the presence-lock banner nor the held-fit // banner can survive the reclaim. The desktop renderer refits the PTY to real // dims on its next settled frame. private releaseDesktopTakeBack(ptyId: string): void { this.setDriver(ptyId, { kind: 'desktop' }) if (this.terminalFitOverrides.has(ptyId)) { this.terminalFitOverrides.delete(ptyId) this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) } } // Why: read-side clamp for mobileAutoRestoreFitMs. `null` means // indefinite hold (no auto-restore timer). A finite value is clamped // to [MIN, MAX] to defend against bad config — the smallest useful // value is a few seconds, the largest is one hour. See // docs/mobile-fit-hold.md. private getAutoRestoreFitMs(): number | null { const raw = this.store?.getSettings().mobileAutoRestoreFitMs ?? null if (raw == null) { return null } if (typeof raw !== 'number' || !Number.isFinite(raw)) { return null } return Math.min(Math.max(raw, MOBILE_AUTO_RESTORE_FIT_MIN_MS), MOBILE_AUTO_RESTORE_FIT_MAX_MS) } // Why: invoked when the user changes mobileAutoRestoreFitMs to `null` // (Indefinite). Clears every pending restore timer so the just-expressed // preference "do not auto-restore" is honored for ALL currently-pending // PTYs, not just one. See docs/mobile-fit-hold.md. cancelAllPendingFitRestoreTimers(): void { for (const [, entry] of this.pendingRestoreTimers) { clearTimeout(entry.timer) } this.pendingRestoreTimers.clear() } // Why: read the persisted user preference (clamped) for surfacing to UI // callers (mobile RPC, desktop preferences). Returns null when the // setting is unset or `null` ("Indefinite"). getMobileAutoRestoreFitMs(): number | null { return this.getAutoRestoreFitMs() } // Why: persisted-preference setter routed through the same `Store` the // desktop preferences UI writes to. Transitions to `null` (Indefinite) // clear every pending restore timer to honor the preference change for // already-held PTYs. Transitions to a finite value do NOT retroactively // schedule timers for PTYs that are currently held — those PTYs were // already-not-restored under the old preference, and silently scheduling // a restore on a settings change would be surprising. The new value // takes effect on the next unsubscribe. See docs/mobile-fit-hold.md. setMobileAutoRestoreFitMs(ms: number | null): number | null { if (!this.store?.updateSettings) { return this.getAutoRestoreFitMs() } let normalized: number | null if (ms == null) { normalized = null } else if (typeof ms !== 'number' || !Number.isFinite(ms)) { normalized = null } else { normalized = Math.min( Math.max(ms, MOBILE_AUTO_RESTORE_FIT_MIN_MS), MOBILE_AUTO_RESTORE_FIT_MAX_MS ) } this.store.updateSettings({ mobileAutoRestoreFitMs: normalized }, { notifyListeners: true }) if (normalized == null) { this.cancelAllPendingFitRestoreTimers() } return normalized } // Why: with multiple subscribers, the active phone-fit dims follow the // most recent mobile actor (argmax(lastActedAt)). See // docs/mobile-presence-lock.md "Active phone-fit dim selection". private pickMostRecentActor( inner: Map ): { clientId: string; lastActedAt: number } | null { let best: { clientId: string; lastActedAt: number } | null = null for (const sub of inner.values()) { if (best === null || sub.lastActedAt > best.lastActedAt) { best = sub } } return best } // Why: restore-target selection on last-subscriber-leaves picks the // earliest-by-subscribe-time subscriber AMONG those with non-null // previousCols/Rows. Desktop-mode joins carry null and are skipped — they // never captured pre-fit dims by design. private pickEarliestRestoreTarget( inner: Map< string, { subscribedAt: number; previousCols: number | null; previousRows: number | null } > ): { previousCols: number; previousRows: number } | null { let best: { subscribedAt: number; previousCols: number; previousRows: number } | null = null for (const sub of inner.values()) { if (sub.previousCols == null || sub.previousRows == null) { continue } if (best === null || sub.subscribedAt < best.subscribedAt) { best = { subscribedAt: sub.subscribedAt, previousCols: sub.previousCols, previousRows: sub.previousRows } } } return best ? { previousCols: best.previousCols, previousRows: best.previousRows } : null } // ─── Layout state machine ───────────────────────────────────────── // // See docs/mobile-terminal-layout-state-machine.md. // // applyLayout is the SOLE writer of: // - this.layouts // - this.terminalFitOverrides (except the sanctioned dead-pty cleanups in // onPtyExit and reclaimTerminalForDesktop's orphan branch, which delete) // - this.ptyController.resize (i.e. the actual PTY dims) // // Every trigger that wants to change PTY dims or flip mode goes through // enqueueLayout, which serializes calls behind a per-PTY async queue // (the await on ptyController.resize would otherwise let seq bumps reach // the wire out of order). getLayout(ptyId: string): PtyLayoutState | null { return this.layouts.get(ptyId) ?? null } // Why: `enqueueLayout`'s "no layouts entry" short-circuit must not fire // on the very first transition for a PTY (where the entry doesn't exist // yet *because* we're about to create it). handleMobileSubscribe adds // the ptyId to `freshSubscribeGuard` before calling enqueueLayout and // removes it in a finally block. private isFreshSubscribe(ptyId: string): boolean { return this.freshSubscribeGuard.has(ptyId) } // Why: four-step fallback chain for desktop-restore targets. Always // returns a value; the terminal {80,24} branch is reached only under // bug. Wrapping the chain as a single helper prevents callsite drift. private resolveDesktopRestoreTarget(ptyId: string): { cols: number; rows: number } { // 1. Earliest-by-subscribedAt subscriber with non-null baseline. const inner = this.mobileSubscribers.get(ptyId) if (inner) { const earliest = this.pickEarliestRestoreTarget(inner) if (earliest) { return { cols: earliest.previousCols, rows: earliest.previousRows } } } // 2. Most-recent desktop renderer geometry report. const renderer = this.lastRendererSizes.get(ptyId) if (renderer) { return { cols: renderer.cols, rows: renderer.rows } } // 3. Current PTY size. const size = this.getTerminalSize(ptyId) if (size) { return { cols: size.cols, rows: size.rows } } // 4. Hard default. return { cols: 80, rows: 24 } } // Why: a new viewport-only update from the same owner supersedes a // queued same-shape tail. Mode flips, owner changes, and take-back // append (losing a take-floor to a viewport tick would be a fairness // hole — see "enqueueLayout coalescing" in the design doc). private coalescesWith(prev: PtyLayoutTarget, next: PtyLayoutTarget): boolean { if (prev.kind !== next.kind) { return false } if (prev.kind === 'phone' && next.kind === 'phone') { return prev.ownerClientId === next.ownerClientId } if (prev.kind === 'remote-desktop' && next.kind === 'remote-desktop') { // Why: each owner's claim promise gates its following input. Sharing a // waiter across owners could release A's input only after B's grid lands. return prev.ownerSubscriptionKey === next.ownerSubscriptionKey } return true } private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise { // Why: PTY-exit short-circuit. Fresh-subscribe gate lets the very first // transition through even though `layouts` has no entry yet. if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { return Promise.resolve({ ok: false, reason: 'pty-exited' }) } let entry = this.layoutQueues.get(ptyId) if (!entry) { entry = { running: null, pending: [] } this.layoutQueues.set(ptyId, entry) } const queue = entry return new Promise((resolve) => { if (!queue.running) { queue.running = this.runLayoutSlot(ptyId, target, [resolve]) return } const tail = queue.pending.at(-1) if (tail && this.coalescesWith(tail.target, target)) { tail.target = target tail.waiters.push(resolve) return } queue.pending.push({ target, waiters: [resolve] }) }) } private async runLayoutSlot( ptyId: string, target: PtyLayoutTarget, waiters: ((r: ApplyLayoutResult) => void)[] ): Promise { let result: ApplyLayoutResult try { result = await this.applyLayout(ptyId, target) } catch (err) { // Why: defensive — applyLayout itself catches resize errors, but a // throw from one of the synchronous map writes (e.g. notifier hook) // must not jam the queue forever. console.error('[layout] applyLayout threw', { ptyId, err }) result = { ok: false, reason: 'resize-failed' } } for (const w of waiters) { w(result) } const queue = this.layoutQueues.get(ptyId) if (!queue) { return result } const next = queue.pending.shift() if (next) { queue.running = this.runLayoutSlot(ptyId, next.target, next.waiters) } else { queue.running = null // Why: drop the entry once empty so the map doesn't grow without bound // across short-lived PTYs. this.layoutQueues.delete(ptyId) } return result } private async applyLayout(ptyId: string, target: PtyLayoutTarget): Promise { // Why: re-check pty-exit at the head of the slot — the queue may have // accepted this target before onPtyExit ran. if (!this.layouts.has(ptyId) && !this.isFreshSubscribe(ptyId)) { return { ok: false, reason: 'pty-exited' } } const prev = this.layouts.get(ptyId) ?? null const seq = (prev?.seq ?? 0) + 1 const next: PtyLayoutState = { ...target, seq, appliedAt: Date.now() } const currentSize = this.getTerminalSize(ptyId) const dimsChanged = currentSize?.cols !== target.cols || currentSize?.rows !== target.rows const modeChanged = (prev?.kind ?? 'desktop') !== target.kind // Snapshot for rollback. const prevFitOverride = this.terminalFitOverrides.get(ptyId) ?? null // Tentative writes — the resize is the point of no return. this.layouts.set(ptyId, next) if (target.kind === 'phone') { // Why: pull baseline cols+rows atomically from the same subscriber so // they can't desync. const baseline = (() => { const inner = this.mobileSubscribers.get(ptyId) if (!inner) { return null } return this.pickEarliestRestoreTarget(inner) })() this.terminalFitOverrides.set(ptyId, { mode: 'mobile-fit', cols: target.cols, rows: target.rows, previousCols: baseline?.previousCols ?? null, previousRows: baseline?.previousRows ?? null, updatedAt: next.appliedAt, clientId: target.ownerClientId }) } else { this.terminalFitOverrides.delete(ptyId) } if (dimsChanged) { let ok = false try { const r = this.ptyController?.resize?.(ptyId, target.cols, target.rows) ok = r ?? true } catch (err) { console.error('[layout] ptyController.resize threw', { ptyId, err }) ok = false } if (!ok) { // Roll back to pre-call snapshot. seq is NOT bumped on the wire // because we never emit below. if (prev) { this.layouts.set(ptyId, prev) } else { this.layouts.delete(ptyId) } if (prevFitOverride) { this.terminalFitOverrides.set(ptyId, prevFitOverride) } else { this.terminalFitOverrides.delete(ptyId) } return { ok: false, reason: 'resize-failed' } } this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) } // Why: remote desktop ownership is a fit hold for the host and passive // peer viewers. Emit every remote layout so owner changes at equal geometry // still park/release the correct clients without relying on resize deltas. // Defense-in-depth (#7588): also emit when the override's presence // changed even without a kind flip. applyLayout is the sole writer and // keeps override presence in lockstep with layout kind, so overrideChanged // ≡ modeChanged in every reachable state today; the extra clause fires // only if that invariant is ever violated, repairing the renderer instead // of stranding the held modal. const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') if (target.kind === 'remote-desktop' || modeChanged || overrideChanged) { // Why: phone→desktop arms the renderer-cascade suppress window // before the collateral safeFit IPCs arrive. See "Renderer cascade // suppression". if (target.kind === 'desktop') { this.lastRendererSizes.delete(ptyId) this.suppressResizesForMs(500) } this.notifier?.terminalFitOverrideChanged( ptyId, target.kind === 'phone' ? 'mobile-fit' : target.kind === 'remote-desktop' ? 'remote-desktop-fit' : 'desktop-fit', target.cols, target.rows ) this.notifyFitOverrideListeners( ptyId, target.kind === 'phone' ? 'mobile-fit' : target.kind === 'remote-desktop' ? 'remote-desktop-fit' : 'desktop-fit', target.cols, target.rows ) } // Mobile-facing event always fires (phone clients need to re-fit on // every dim change, not just mode flips). this.notifyTerminalResize(ptyId, { cols: target.cols, rows: target.rows, displayMode: target.kind === 'phone' ? 'phone' : 'desktop', reason: 'apply-layout', seq }) return { ok: true, state: next } } // ─── Server-Authoritative Mobile Display Mode ───────────────────── setMobileDisplayMode(ptyId: string, mode: 'auto' | 'desktop'): void { if (mode === 'auto') { this.mobileDisplayModes.delete(ptyId) } else { this.mobileDisplayModes.set(ptyId, mode) } } getMobileDisplayMode(ptyId: string): 'auto' | 'desktop' { return this.mobileDisplayModes.get(ptyId) ?? 'auto' } isMobileSubscriberActive(ptyId: string): boolean { const inner = this.mobileSubscribers.get(ptyId) return inner !== undefined && inner.size > 0 } // Why: late-bind viewport on an existing subscriber record. Subscribers // that registered before the mobile side measured (e.g. terminal first // mounted while the WebView was still loading) have null viewport, and // applyMobileDisplayMode's auto branch needs a viewport to phone-fit. // The setDisplayMode RPC carries the latest viewport so we can patch it // here just before applyMobileDisplayMode runs. updateMobileSubscriberViewport( ptyId: string, clientId: string, viewport: { cols: number; rows: number } ): void { const inner = this.mobileSubscribers.get(ptyId) const record = inner?.get(clientId) if (!record) { return } record.viewport = viewport } // Why: server-side auto-fit on mobile subscribe. The runtime is the single // source of truth — the mobile client just passes its viewport and the runtime // decides whether to resize. This eliminates the measure→RPC→resubscribe // pipeline that caused race conditions. // // Multi-mobile keying: each subscriber lives in `mobileSubscribers[ptyId]`'s // inner map under its own clientId. Phone B subscribing does not overwrite // phone A's record — both stay until each unsubscribes. // // Subscribe-in-desktop-mode rule: a subscribe with displayMode='desktop' is // a passive watch; it does NOT take the floor. The driver remains // `idle`/`desktop`. The lock banner is reserved for actual mobile // interaction (input/resize/setDisplayMode/auto-or-phone subscribe). async handleMobileSubscribe( ptyId: string, clientId: string, viewport?: { cols: number; rows: number } ): Promise { try { return await this.handleMobileSubscribeInternal(ptyId, clientId, viewport) } finally { // Every subscribe path mutates mobileSubscribers — resync the daemon // background mark once, whatever branch returned. this.notifyRemoteTerminalViewPresenceChanged(ptyId) } } private async handleMobileSubscribeInternal( ptyId: string, clientId: string, viewport?: { cols: number; rows: number } ): Promise { const mode = this.getMobileDisplayMode(ptyId) // Cancel pending restore timer for this ptyId — any new subscriber // supersedes any old client's pending restore. const pendingRestore = this.pendingRestoreTimers.get(ptyId) if (pendingRestore) { clearTimeout(pendingRestore.timer) this.pendingRestoreTimers.delete(ptyId) } // Resubscribe-grace honor: same client returning within soft-leave // window restores prior record (preserving baseline so we don't capture // phone-fitted dims as the new baseline). const softLeaver = this.pendingSoftLeavers.get(ptyId) if (softLeaver && softLeaver.clientId === clientId) { clearTimeout(softLeaver.timer) this.pendingSoftLeavers.delete(ptyId) let inner = this.mobileSubscribers.get(ptyId) if (!inner) { inner = new Map() this.mobileSubscribers.set(ptyId, inner) } inner.set(clientId, { ...softLeaver.record, viewport: viewport ?? null, lastActedAt: Date.now() }) if (!viewport) { return false } this.setDriver(ptyId, { kind: 'mobile', clientId }) if (mode !== 'desktop') { const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( viewport.cols, viewport.rows ) this.freshSubscribeGuard.add(ptyId) try { await this.enqueueLayout(ptyId, { kind: 'phone', cols: clampedCols, rows: clampedRows, ownerClientId: clientId }) } finally { this.freshSubscribeGuard.delete(ptyId) } } return true } let inner = this.mobileSubscribers.get(ptyId) if (!inner) { inner = new Map() this.mobileSubscribers.set(ptyId, inner) } // Capture restore baseline BEFORE applyLayout writes the override. // Multi-mobile: peer joiner against an already-fitted PTY captures null // — the existing baseline-holder's snapshot remains canonical. See // docs/mobile-presence-lock.md. // // Resubscribe-after-indefinite-hold: the held override carries the only // authoritative pre-fit dims across the no-subscriber gap. Inherit it // first; otherwise rendererSize/currentSize would be the held phone dims // and applyLayout would clobber the override's previousCols with phone // dims, making any subsequent Restore a no-op. const heldOverride = this.terminalFitOverrides.get(ptyId) const existing = inner.get(clientId) const someoneAlreadyFitted = [...inner.values()].some((s) => s.wasResizedToPhone) const currentSize = this.getTerminalSize(ptyId) const rendererSize = this.lastRendererSizes.get(ptyId) const previousCols = existing?.previousCols ?? heldOverride?.previousCols ?? (someoneAlreadyFitted ? null : (rendererSize?.cols ?? currentSize?.cols ?? null)) const previousRows = existing?.previousRows ?? heldOverride?.previousRows ?? (someoneAlreadyFitted ? null : (rendererSize?.rows ?? currentSize?.rows ?? null)) const now = Date.now() const subscribedAt = existing?.subscribedAt ?? now if (!viewport) { // Why: mobile can subscribe before its WebView has measured. Keep the // subscriber + desktop baseline so updateViewport/setDisplayMode can // late-bind the viewport without recapturing phone dims. inner.set(clientId, { clientId, viewport: null, wasResizedToPhone: false, previousCols, previousRows, subscribedAt, lastActedAt: now }) return false } const { cols: clampedCols, rows: clampedRows } = clampTerminalViewport( viewport.cols, viewport.rows ) if (mode === 'desktop') { // Passive watch — null baseline (we'll capture later if user toggles // to auto/phone, since safeFit will have converged by then). Do not // flip driver. inner.set(clientId, { clientId, viewport, wasResizedToPhone: false, previousCols: null, previousRows: null, subscribedAt, lastActedAt: now }) return false } inner.set(clientId, { clientId, viewport, wasResizedToPhone: true, previousCols, previousRows, subscribedAt, lastActedAt: now }) // Subscribe-fresh with auto/phone counts as "take the floor". this.setDriver(ptyId, { kind: 'mobile', clientId }) // Route the actual resize through the state machine. The fresh-subscribe // gate lets enqueueLayout's "no layouts entry" short-circuit pass on // the very first transition for this PTY. this.freshSubscribeGuard.add(ptyId) try { await this.enqueueLayout(ptyId, { kind: 'phone', cols: clampedCols, rows: clampedRows, ownerClientId: clientId }) } finally { this.freshSubscribeGuard.delete(ptyId) } return true } // Why: delayed restore prevents resize thrashing during rapid tab switches. // The 300ms debounce means only the final tab triggers a PTY restore; // intermediate terminals keep their current dims harmlessly. // // Multi-mobile: only the last subscriber leaving for this ptyId triggers // restore + driver=idle. Peer mobile clients still on the inner map keep // the lock banner mounted; if the disconnecting client was the active // driver, we re-elect the most-recent surviving subscriber. handleMobileUnsubscribe(ptyId: string, clientId: string): void { const inner = this.mobileSubscribers.get(ptyId) if (!inner) { return } const subscriber = inner.get(clientId) if (!subscriber) { return } const wasResizedToPhone = subscriber.wasResizedToPhone inner.delete(clientId) this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { // Why: if the leaving client was the only one with a non-null restore // baseline (typical when peer joiners subscribed against an // already-phone-fitted PTY and got null prevCols), donate the baseline // to the earliest surviving subscriber so a future last-leaver can // still restore correctly. See docs/mobile-presence-lock.md. if ( subscriber.previousCols != null && subscriber.previousRows != null && !this.pickEarliestRestoreTarget(inner) ) { let earliestSurvivor: { clientId: string; subscribedAt: number } | null = null for (const sub of inner.values()) { if (earliestSurvivor === null || sub.subscribedAt < earliestSurvivor.subscribedAt) { earliestSurvivor = { clientId: sub.clientId, subscribedAt: sub.subscribedAt } } } if (earliestSurvivor) { const heir = inner.get(earliestSurvivor.clientId) if (heir) { heir.previousCols = subscriber.previousCols heir.previousRows = subscriber.previousRows } } } // Peers still on the line. If the disconnecting client was the active // mobile driver, re-elect the most-recent surviving subscriber so the // banner remains correct and active phone-fit dims follow them. const driver = this.getDriver(ptyId) if (driver.kind === 'mobile' && driver.clientId === clientId) { const next = this.pickMostRecentActor(inner) if (next) { this.setDriver(ptyId, { kind: 'mobile', clientId: next.clientId }) // Fire-and-forget — handleMobileUnsubscribe stays sync; applyLayout // failures self-recover on the next gesture. void this.applyMobileDisplayMode(ptyId) } } return } // Last subscriber leaving — clean up. this.mobileSubscribers.delete(ptyId) const mode = this.getMobileDisplayMode(ptyId) // Resubscribe-grace: hold driver=mobile{clientId} for ~250ms so a quick // re-subscribe (older clients without updateViewport) doesn't flash the // desktop banner. See docs/mobile-presence-lock.md. const SOFT_LEAVE_GRACE_MS = 250 const existingSoft = this.pendingSoftLeavers.get(ptyId) if (existingSoft) { clearTimeout(existingSoft.timer) this.pendingSoftLeavers.delete(ptyId) } const softTimer = setTimeout(() => { this.pendingSoftLeavers.delete(ptyId) if (!this.mobileSubscribers.has(ptyId)) { this.setDriver(ptyId, { kind: 'idle' }) if (this.hasRemoteDesktopViewers(ptyId)) { void this.applyRemoteDesktopLayout(ptyId) } } }, SOFT_LEAVE_GRACE_MS) if (typeof softTimer.unref === 'function') { softTimer.unref() } this.pendingSoftLeavers.set(ptyId, { clientId, timer: softTimer, record: { clientId: subscriber.clientId, viewport: subscriber.viewport, wasResizedToPhone: subscriber.wasResizedToPhone, previousCols: subscriber.previousCols, previousRows: subscriber.previousRows, subscribedAt: subscriber.subscribedAt, lastActedAt: subscriber.lastActedAt } }) if (mode === 'auto' && wasResizedToPhone) { const existingTimer = this.pendingRestoreTimers.get(ptyId) if (existingTimer) { clearTimeout(existingTimer.timer) this.pendingRestoreTimers.delete(ptyId) } // Why: scheduling is conditional on the user's mobileAutoRestoreFitMs // preference. `null` (default, "Indefinite") leaves the PTY at phone // dims until the user clicks Restore on the desktop banner — the // central UX promise of docs/mobile-fit-hold.md. A finite value runs // the restore that long after the last unsubscribe. const autoRestoreMs = this.getAutoRestoreFitMs() if (autoRestoreMs == null) { // Indefinite hold: the fit override persists, the SOFT_LEAVE_GRACE // driver-state grace above still releases the input lock, and the // banner's Restore button is the explicit return path. } else { // Snapshot the disconnecting subscriber's baseline NOW, before the // timer fires. By the time the timer runs, the subscriber map has // been deleted; resolveDesktopRestoreTarget would fall through to // lastRendererSizes → current PTY size (which is at phone dims, // wrong). The disconnecting subscriber's baseline is the correct // restore target. const fallback = this.lastRendererSizes.get(ptyId) const restoreCols = subscriber.previousCols ?? fallback?.cols ?? this.getTerminalSize(ptyId)?.cols ?? 80 const restoreRows = subscriber.previousRows ?? fallback?.rows ?? this.getTerminalSize(ptyId)?.rows ?? 24 const timer = setTimeout(() => { this.pendingRestoreTimers.delete(ptyId) if (this.isMobileSubscriberActive(ptyId)) { return } if (this.hasRemoteDesktopLayoutState(ptyId)) { void this.applyRemoteDesktopLayout(ptyId) return } void this.enqueueLayout(ptyId, { kind: 'desktop', cols: restoreCols, rows: restoreRows }) }, autoRestoreMs) // Why: a delayed mobile restore should not keep Electron main alive // after the last window/runtime transport has otherwise shut down. if (typeof timer.unref === 'function') { timer.unref() } this.pendingRestoreTimers.set(ptyId, { timer, clientId }) } } // 'desktop' mode: was never resized, nothing to restore. } // Why: called when mode changes via terminal.setDisplayMode. Applies the // mode change immediately if there's an active subscriber, and emits a // 'resized' event so the mobile client can reinitialize xterm inline. // // Multi-mobile: the most recent mobile actor's viewport drives the active // phone-fit dims. The earliest-by-subscribe-time subscriber's // previousCols/Rows drive the desktop-restore target. // // Returns the post-condition "no fit-override remains held" (#7588): `true` // when it cleared a held override OR nothing was held to begin with, `false` // only when a restore was attempted and the resize failed (override rolled // back, still held). reclaimTerminalForDesktop gates its driver/mode // transitions on this; other callers ignore it. async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.getMobileDisplayMode(ptyId) const inner = this.mobileSubscribers.get(ptyId) const subscriber = inner ? this.pickMostRecentActor(inner) : null const subscriberRecord = subscriber && inner ? inner.get(subscriber.clientId) : null if (mode === 'desktop') { // Reset wasResizedToPhone on every fitted subscriber so a future // toggle back to auto re-issues the resize. applyLayout owns the // actual PTY resize + override delete + renderer notify. Track which // subscribers we cleared so a failed resize can re-arm them. const clearedFitSubscribers = inner ? [...inner.values()].filter((sub) => sub.wasResizedToPhone) : [] for (const sub of clearedFitSubscribers) { sub.wasResizedToPhone = false } const anyWasResized = clearedFitSubscribers.length > 0 // Why (#7588): also restore when a fit-override is still held but no // subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe // after an indefinite hold resets the flag yet leaves the override, // stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget // (the same resolver the anyWasResized branch uses) so the two adjacent // restore paths can never resolve to different dims for the same state. if (anyWasResized || this.terminalFitOverrides.has(ptyId)) { const restore = this.resolveDesktopRestoreTarget(ptyId) const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols: restore.cols, rows: restore.rows }) // Why (#7588): a failed resize rolls the override back (still held), so // re-arm the flags we cleared. Otherwise a later unsubscribe under a // finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip // scheduling its auto-restore timer, and strand the held phone-fit. if (!result.ok) { for (const sub of clearedFitSubscribers) { sub.wasResizedToPhone = true } } } else { // Nothing was fitted or held — emit a mode-change resize event so // the mobile client still learns the toggle landed. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { cols: size?.cols ?? 0, rows: size?.rows ?? 0, displayMode: 'desktop', reason: 'mode-change', seq: this.layouts.get(ptyId)?.seq }) } } else { // mode === 'auto' — the only non-desktop mode after the 'phone' // (sticky-fit) collapse. Phone-fit if the active subscriber has a // viewport and we haven't already applied it. if (subscriberRecord && !subscriberRecord.wasResizedToPhone) { const viewport = subscriberRecord.viewport if (viewport) { await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) // After a phone-fit an override IS held, so this reports false. The // auto branch is never reached from reclaim (it sets 'desktop' // first); computed here only to keep the post-condition uniform. return !this.terminalFitOverrides.has(ptyId) } } // Why: always emit the mode change even when no resize occurred — the // mobile client needs to learn the toggle landed even if dims didn't // actually change. Carry the current seq (or undefined if no layout // entry yet) so the mobile-side stale-event filter behaves correctly. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { cols: size?.cols ?? 0, rows: size?.rows ?? 0, displayMode: 'auto', reason: 'mode-change', seq: this.layouts.get(ptyId)?.seq }) } return !this.terminalFitOverrides.has(ptyId) } // Why: called after a desktop renderer path has successfully resized the // PTY (local IPC or remote desktop viewport). The runtime mirror must take // the same accepted geometry so hidden-output restore parses at PTY width. onExternalPtyResize(ptyId: string, cols: number, rows: number): void { // The pty:resize IPC handler is supposed to gate via `isResizeSuppressed` // before calling here, but defend against callers that don't. if (this.isResizeSuppressed()) { return } // Why: while a mobile-fit override is in place, the desktop renderer's // safeFit echoes pty:resize(override.cols, override.rows). Treating that // echo as legitimate geometry would overwrite each subscriber's // previousCols/Rows baseline with phone dims, so the next take-back // enqueues a no-op {kind:'desktop', cols:49, rows:40} and leaves xterm // stuck. Only filter reports that EXACTLY match the override — a fresh // measurement from a now-visible pane (e.g. user activated a previously // hidden tab on desktop, container went 0×0 → 1782×1195) reports // different dims and is the right baseline to remember. const activeOverride = this.terminalFitOverrides.get(ptyId) if (activeOverride && activeOverride.cols === cols && activeOverride.rows === rows) { return } // Why: a successful host resize supersedes any target retained after a // failed viewer reclaim; a later viewer cycle must capture this new truth. if (!this.hasRemoteDesktopViewers(ptyId)) { this.remoteDesktopHostReclaimTargets.delete(ptyId) } this.resizeHeadlessTerminal(ptyId, cols, rows) this.refreshRendererGeometry(ptyId, cols, rows) } // Why: pty:reportGeometry IPC sibling. The renderer calls this when a // desktop pane container goes from 0×0 to a real size while a mobile-fit // override is active (e.g. user activates a previously-hidden tab on // desktop after the phone has already taken the floor). We need the // restore-target baseline to track real desktop dims even during the // fit period — otherwise resolveDesktopRestoreTarget falls back to the // PTY's spawn default (typically 80×24) and Take Back leaves the // terminal partially restored. This is a measurement-only channel: it // refreshes lastRendererSizes and non-null subscriber baselines, never // resizes the PTY, and bypasses both isResizeSuppressed and the // override-echo gate by design — the renderer only fires it when it // has just measured fresh real geometry. See docs/mobile-fit-hold.md. recordRendererGeometry(ptyId: string, cols: number, rows: number): void { if (cols <= 0 || rows <= 0) { return } // Why: a viewer may leave while phone-fit still owns the PTY. Keep its // deferred host reclaim cache aligned with later trusted pane measurements. if (this.remoteDesktopHostReclaimTargets.has(ptyId)) { this.remoteDesktopHostReclaimTargets.set(ptyId, { cols, rows }) } this.refreshRendererGeometry(ptyId, cols, rows) } // Why: test seam — exposes lastRendererSizes for assertions about // pty:reportGeometry / onExternalPtyResize side effects without making // the underlying Map writable from the outside. getLastRendererSize(ptyId: string): { cols: number; rows: number } | null { return this.lastRendererSizes.get(ptyId) ?? null } private refreshRendererGeometry(ptyId: string, cols: number, rows: number): void { this.lastRendererSizes.set(ptyId, { cols, rows }) const inner = this.mobileSubscribers.get(ptyId) if (!inner) { return } // Refresh the renderer-current size as the next-restore target on every // subscriber that already has a non-null baseline. Subscribers with null // baselines (joined while a peer had already phone-fitted) stay null. for (const sub of inner.values()) { if (sub.previousCols != null && sub.previousRows != null) { sub.previousCols = cols sub.previousRows = rows } } } // Why: the pty:resize IPC handler calls this to check if the global // suppress window is active. During this window, all desktop renderer // pty:resize events are ignored to prevent collateral safeFit corruption. isResizeSuppressed(): boolean { return Date.now() < this.resizeSuppressedUntil } private suppressResizesForMs(ms: number): void { this.resizeSuppressedUntil = Date.now() + ms } subscribeToTerminalResize( ptyId: string, listener: (event: { cols: number rows: number displayMode: string reason: string seq?: number }) => void ): () => void { return addListenerToMap(this.resizeListeners, ptyId, listener) } private notifyTerminalResize( ptyId: string, event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } ): void { const listeners = this.resizeListeners.get(ptyId) if (!listeners) { return } notifyRuntimeListeners(listeners, (listener) => listener(event), 'pty-resize') } // Why: Section 7.2 — the runtime detects agent exit directly and updates // dispatch contexts immediately, rather than waiting for the coordinator's // next poll cycle. This catches agent crashes and unexpected exits within // milliseconds. The task is set back to 'pending' so it can be re-dispatched. private failActiveDispatchOnExit(leaf: RuntimeLeafRecord, exitCode: number): void { if (!this._orchestrationDb) { return } const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (!handle) { return } const dispatch = this._orchestrationDb.getActiveDispatchForTerminal(handle) if (!dispatch) { return } const errorContext = `Agent exited with code ${exitCode}` this._orchestrationDb.failDispatch(dispatch.id, errorContext) // Why: create an escalation message so the coordinator is notified about // the unexpected exit on its next check cycle, even if the circuit breaker // hasn't tripped yet. const run = this._orchestrationDb.getActiveCoordinatorRun() if (run) { this._orchestrationDb.insertMessage({ from: handle, to: run.coordinator_handle, subject: `Agent exited unexpectedly (code ${exitCode})`, type: 'escalation', priority: 'high', payload: JSON.stringify({ taskId: dispatch.task_id, exitCode, handle }) }) } } async listTerminals( worktreeSelector?: string, limit = DEFAULT_TERMINAL_LIST_LIMIT, opts: { handles?: readonly string[] requireFreshPtyLiveness?: boolean includeVisualLayouts?: boolean } = {} ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null const explicitTargetWorktreeId = worktreeSelector ? this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) : null const initialResolvedWorktreeCache = this.resolvedWorktreeCache const cachedResolvedWorktrees = initialResolvedWorktreeCache && initialResolvedWorktreeCache.expiresAt > Date.now() ? initialResolvedWorktreeCache.worktrees : null const cachedExplicitTargetWorktree = explicitTargetWorktreeId && cachedResolvedWorktrees ? (cachedResolvedWorktrees.find((worktree) => worktree.id === explicitTargetWorktreeId) ?? null) : null const parsedExplicitTargetWorktree = explicitTargetWorktreeId && !cachedExplicitTargetWorktree ? this.buildResolvedWorktreeFromId(explicitTargetWorktreeId) : null const targetWorktree = worktreeSelector && !explicitTargetWorktreeId ? await this.resolveWorktreeSelector(worktreeSelector) : (cachedExplicitTargetWorktree ?? parsedExplicitTargetWorktree) const targetWorktreeId = explicitTargetWorktreeId ?? targetWorktree?.id ?? null const classificationResolvedWorktreeCache = this.resolvedWorktreeCache const classificationResolvedWorktrees = targetWorktreeId && classificationResolvedWorktreeCache && classificationResolvedWorktreeCache.expiresAt > Date.now() ? includeTargetResolvedWorktree( classificationResolvedWorktreeCache.worktrees, targetWorktree ) : targetWorktreeId && explicitTargetWorktreeId ? this.listKnownResolvedWorktreesForExplicitTarget(targetWorktreeId, targetWorktree) : null const worktreesById = targetWorktreeId && targetWorktree ? new Map([[targetWorktree.id, targetWorktree]]) : targetWorktreeId ? new Map() : await this.getResolvedWorktreeMap() if (graphEpoch !== null) { this.assertStableReadyGraph(graphEpoch) } const resolvedWorktrees = targetWorktreeId && classificationResolvedWorktrees ? classificationResolvedWorktrees : targetWorktreeId && targetWorktree ? [targetWorktree] : targetWorktreeId ? [] : [...worktreesById.values()] const controllerInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, targetWorktreeId ) const refreshedPtyLiveness = controllerInventory ? new Set(controllerInventory.livePtyIds) : null if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } // Why: a proof of absence, not a proof of liveness — leaves whose PTY the // controller answered for but did not list must not read as connected. An // unavailable inventory (null) proves nothing and demotes nothing. const provenLivePtyIds = controllerInventory?.allLivePtyIds ?? null const livePtyWorktreeIds = new Set() for (const pty of this.ptysById.values()) { if (pty.connected) { livePtyWorktreeIds.add(pty.worktreeId) } } const terminals: RuntimeTerminalSummary[] = [] const ptyIdsFromLeaves = new Set() if (graphEpoch !== null) { for (const leaf of this.leaves.values()) { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } if ( opts.requireFreshPtyLiveness && (!leaf.ptyId || !refreshedPtyLiveness?.has(leaf.ptyId)) ) { continue } if (!leaf.ptyId && livePtyWorktreeIds.has(leaf.worktreeId)) { continue } if (leaf.ptyId) { ptyIdsFromLeaves.add(leaf.ptyId) } terminals.push(this.buildTerminalSummary(leaf, worktreesById, provenLivePtyIds)) } } // Why: worktree.ps can classify active worktrees from PTY records even when // the renderer graph is missing a leaf. terminal.list needs the same fallback // so mobile does not show a false "No terminals" create flow. for (const pty of this.ptysById.values()) { if (!pty.connected || ptyIdsFromLeaves.has(pty.ptyId)) { continue } if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness?.has(pty.ptyId)) { continue } if (targetWorktreeId && pty.worktreeId !== targetWorktreeId) { continue } terminals.push(this.buildPtyTerminalSummary(pty, worktreesById)) } const requestedHandles = opts.handles ? new Set(opts.handles) : null const matchingTerminals = requestedHandles ? terminals.filter((terminal) => requestedHandles.has(terminal.handle)) : terminals const listedTerminals = matchingTerminals.slice(0, limit) // Why: undefined (pre-flag client) must still get layouts; only an explicit // `false` opts out. const visualLayouts = opts.includeVisualLayouts === false ? [] : this.buildTerminalVisualLayouts(listedTerminals, worktreesById, targetWorktreeId) return { terminals: listedTerminals, ...(visualLayouts.length > 0 ? { visualLayouts } : {}), topologyRevisions: Object.fromEntries( [...new Set(matchingTerminals.map((terminal) => terminal.worktreeId))].map((worktreeId) => [ worktreeId, this.getTerminalTopologyRevision(worktreeId) ]) ), totalCount: matchingTerminals.length, truncated: matchingTerminals.length > limit } } private getTerminalTopologyRevision(worktreeId: string): number { const repoId = getRepoIdFromWorktreeId(worktreeId) return ( this.getWorkspaceSessionForWorktree(worktreeId)?.terminalTopologyRevisionByRepoId?.[repoId] ?? this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0 ) } async adoptTerminalOrphans( request: RuntimeTerminalOrphanAdoptionRequest ): Promise { if (request.claims.length === 0) { throw new Error('terminal_orphan_claims_required') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) const resolvedWorkspace = workspace.folderWorkspace ? this.folderWorkspaceToResolvedWorktree(workspace.folderWorkspace) : await this.resolveWorktreeSelector(`id:${workspace.id}`) const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( [resolvedWorkspace], workspace.id, undefined, workspace.connectionId ?? null ) if (!inventory) { throw new Error('terminal_liveness_unavailable') } return this.adoptTerminalOrphansFromInventory(request, workspace, inventory) } private async adoptTerminalOrphansFromInventory( request: RuntimeTerminalOrphanAdoptionRequest, workspace: TerminalWorkspaceLaunchScope, inventory: PtyControllerInventory ): Promise { const { livePtyIds, terminalIdentityByPtyId } = inventory const store = this.store const session = this.getWorkspaceSessionForWorktree(workspace.id) if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { throw new Error('workspace_session_unavailable') } const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id) if (!sessionWorktreeId) { throw new Error('terminal_orphan_competing_owner') } const repoId = getRepoIdFromWorktreeId(workspace.id) const worktreeConnectionId = workspace.connectionId let worktreeWslDistro: string | null = null if (!worktreeConnectionId && workspace.repo) { try { worktreeWslDistro = getLocalProjectWorktreeGitOptions(this.requireStore(), workspace.repo).wslDistro ?? null } catch { throw new Error('terminal_orphan_owner_mismatch') } } const currentRevision = this.getTerminalTopologyRevision(workspace.id) const seenPtyIds = new Set() const seenPaneKeys = new Set() const validated = request.claims.map((claim) => { const paneKey = makePaneKey(claim.tabId, claim.leafId) if (seenPtyIds.has(claim.ptyId) || seenPaneKeys.has(paneKey)) { throw new Error('terminal_orphan_claim_duplicate') } seenPtyIds.add(claim.ptyId) seenPaneKeys.add(paneKey) const live = this.getLivePtyForHandle(claim.terminal) const pty = live?.pty const controllerIdentity = terminalIdentityByPtyId.get(claim.ptyId) if ( !pty || pty.ptyId !== claim.ptyId || controllerIdentity?.handle !== claim.terminal || controllerIdentity?.incarnationId !== claim.incarnationId || !livePtyIds.has(claim.ptyId) || !pty.connected || !pty.incarnationId || pty.incarnationId !== claim.incarnationId ) { throw new Error('terminal_orphan_stale') } if ( !runtimeWorktreeIdsEqual(pty.worktreeId, workspace.id) || !terminalOrphanExecutionOwnersEqual( { connectionId: worktreeConnectionId, wslDistro: worktreeWslDistro }, { connectionId: pty.connectionId ?? null, ...(controllerIdentity?.wslDistro !== undefined ? { wslDistro: controllerIdentity.wslDistro } : process.platform === 'win32' && !worktreeConnectionId ? {} : { wslDistro: null }) } ) ) { throw new Error('terminal_orphan_owner_mismatch') } const visualOwners = this.getLeavesForPty(claim.ptyId) if ( visualOwners.some( (owner) => !runtimeWorktreeIdsEqual(owner.worktreeId, workspace.id) || owner.tabId !== claim.tabId || owner.leafId !== claim.leafId ) ) { throw new Error('terminal_orphan_already_visual') } if ((pty.tabId && pty.tabId !== claim.tabId) || (pty.paneKey && pty.paneKey !== paneKey)) { throw new Error('terminal_orphan_competing_owner') } return { claim, pty, paneKey } }) const persistedBindingsByPtyId = new Map() const addPersistedBinding = ( ptyId: string, binding: { worktreeId: string; paneKey: string } ): void => { const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] bindings.push(binding) persistedBindingsByPtyId.set(ptyId, bindings) } for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree)) { for (const tab of tabs) { const layout = session.terminalLayoutsByTabId[tab.id] for (const [leafId, boundPtyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) { if (boundPtyId) { addPersistedBinding(boundPtyId, { worktreeId, paneKey: makePaneKey(tab.id, leafId) }) } } if (tab.ptyId && !layout) { addPersistedBinding(tab.ptyId, { worktreeId, paneKey: tab.id }) } } } const persistedBinding = (ptyId: string): { worktreeId: string; paneKey: string } | null => { const bindings = persistedBindingsByPtyId.get(ptyId) ?? [] if (bindings.length > 1) { throw new Error('terminal_orphan_competing_owner') } return bindings[0] ?? null } const isExactPersisted = validated.every(({ claim, paneKey }) => { const binding = persistedBinding(claim.ptyId) return ( binding !== null && runtimeWorktreeIdsEqual(binding.worktreeId, workspace.id) && binding.paneKey === paneKey && session.terminalPtyIncarnationsByPaneKey?.[paneKey] === claim.incarnationId ) }) if (isExactPersisted && sessionWorktreeId === workspace.id) { for (const { claim, pty, paneKey } of validated) { pty.tabId = claim.tabId pty.paneKey = paneKey } return { adopted: false, topologyRevision: currentRevision, snapshot: this.getTerminalOrphanAdoptionSnapshot(workspace.id) } } if (currentRevision !== request.expectedTopologyRevision) { throw new Error('terminal_topology_conflict') } const topologyTabsById = new Map(request.topology?.tabs.map((tab) => [tab.tabId, tab]) ?? []) const topologyGroups = request.topology?.groups ?? [] if (request.topology) { const claimedLeafIdsByTabId = new Map>() for (const { claim } of validated) { const leafIds = claimedLeafIdsByTabId.get(claim.tabId) ?? new Set() leafIds.add(claim.leafId) claimedLeafIdsByTabId.set(claim.tabId, leafIds) } if ( topologyTabsById.size !== request.topology.tabs.length || topologyTabsById.size !== claimedLeafIdsByTabId.size ) { throw new Error('terminal_orphan_topology_invalid') } for (const [tabId, claimedLeafIds] of claimedLeafIdsByTabId) { const topologyTab = topologyTabsById.get(tabId) if (!topologyTab) { throw new Error('terminal_orphan_topology_invalid') } const topologyLeafIds = new Set() const nodes = [topologyTab.root] let leafCount = 0 while (nodes.length > 0) { const node = nodes.pop()! if (node.type === 'leaf') { leafCount += 1 topologyLeafIds.add(node.leafId) } else { nodes.push(node.first, node.second) } } if ( leafCount !== topologyLeafIds.size || topologyLeafIds.size !== claimedLeafIds.size || [...topologyLeafIds].some((leafId) => !claimedLeafIds.has(leafId)) || !topologyLeafIds.has(topologyTab.activeLeafId) || (topologyTab.expandedLeafId !== null && !topologyLeafIds.has(topologyTab.expandedLeafId)) ) { throw new Error('terminal_orphan_topology_invalid') } } const seenGroupIds = new Set() const groupedTabIds = new Set() for (const group of topologyGroups) { if (seenGroupIds.has(group.id) || !group.tabOrder.includes(group.activeTabId)) { throw new Error('terminal_orphan_topology_invalid') } seenGroupIds.add(group.id) for (const tabId of group.tabOrder) { if (!topologyTabsById.has(tabId) || groupedTabIds.has(tabId)) { throw new Error('terminal_orphan_topology_invalid') } groupedTabIds.add(tabId) } if (group.recentTabIds?.some((tabId) => !group.tabOrder.includes(tabId))) { throw new Error('terminal_orphan_topology_invalid') } } if (groupedTabIds.size !== topologyTabsById.size) { throw new Error('terminal_orphan_topology_invalid') } if (request.topology.groupLayout) { if (!hasExactTerminalOrphanGroupLayout(request.topology.groupLayout, seenGroupIds)) { throw new Error('terminal_orphan_topology_invalid') } } } for (const { claim, paneKey } of validated) { const existingBinding = persistedBinding(claim.ptyId) if ( existingBinding && (!runtimeWorktreeIdsEqual(existingBinding.worktreeId, workspace.id) || existingBinding.paneKey !== paneKey) ) { throw new Error('terminal_orphan_competing_owner') } const proposedPtyId = session.terminalLayoutsByTabId[claim.tabId]?.ptyIdsByLeafId?.[claim.leafId] if (proposedPtyId && proposedPtyId !== claim.ptyId) { throw new Error('terminal_orphan_surface_occupied') } const graphOwner = this.leaves.get(this.getLeafKey(claim.tabId, claim.leafId)) if ( graphOwner && (graphOwner.ptyId !== claim.ptyId || !runtimeWorktreeIdsEqual(graphOwner.worktreeId, workspace.id)) ) { throw new Error('terminal_orphan_surface_occupied') } if ( Object.entries(session.tabsByWorktree).some( ([ownerWorktreeId, tabs]) => !runtimeWorktreeIdsEqual(ownerWorktreeId, workspace.id) && tabs.some((tab) => tab.id === claim.tabId) ) ) { throw new Error('terminal_orphan_surface_occupied') } if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { throw new Error('terminal_orphan_surface_retired') } for (const snapshot of this.mobileSessionTabsByWorktree.values()) { const surfaceOwner = snapshot.tabs.find( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' && tab.parentTabId === claim.tabId && tab.leafId === claim.leafId ) if ( surfaceOwner && (snapshot.worktree !== workspace.id || surfaceOwner.ptyId !== claim.ptyId) ) { throw new Error('terminal_orphan_surface_occupied') } const owner = snapshot.tabs.find( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' && tab.ptyId === claim.ptyId ) if ( owner && (snapshot.worktree !== workspace.id || owner.parentTabId !== claim.tabId || owner.leafId !== claim.leafId) ) { throw new Error('terminal_orphan_competing_owner') } } } const next = structuredClone(session) canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, workspace.id) const existingTabs = next.tabsByWorktree[workspace.id] ?? [] const tabsById = new Map(existingTabs.map((tab) => [tab.id, tab])) for (const { claim, pty, paneKey } of validated) { let tab = tabsById.get(claim.tabId) if (!tab) { const title = getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` tab = { id: claim.tabId, ptyId: claim.ptyId, worktreeId: workspace.id, title, defaultTitle: title, customTitle: null, color: null, sortOrder: tabsById.size, createdAt: Date.now(), pendingActivationSpawn: true } tabsById.set(claim.tabId, tab) } const existingLayout = next.terminalLayoutsByTabId[claim.tabId] const topologyTab = topologyTabsById.get(claim.tabId) next.terminalLayoutsByTabId[claim.tabId] = topologyTab ? { ...existingLayout, root: topologyTab.root, activeLeafId: topologyTab.activeLeafId, expandedLeafId: topologyTab.expandedLeafId, ptyIdsByLeafId: { ...existingLayout?.ptyIdsByLeafId, [claim.leafId]: claim.ptyId } } : existingLayout ? { ...existingLayout, root: this.collectPersistedTerminalLeafIds(existingLayout).includes(claim.leafId) ? existingLayout.root : existingLayout.root === null ? { type: 'leaf', leafId: claim.leafId } : { type: 'split', direction: 'vertical', first: existingLayout.root, second: { type: 'leaf', leafId: claim.leafId } }, ptyIdsByLeafId: { ...existingLayout.ptyIdsByLeafId, [claim.leafId]: claim.ptyId } } : { root: { type: 'leaf', leafId: claim.leafId }, activeLeafId: claim.leafId, expandedLeafId: null, ptyIdsByLeafId: { [claim.leafId]: claim.ptyId } } next.terminalPtyIncarnationsByPaneKey = { ...next.terminalPtyIncarnationsByPaneKey, [paneKey]: claim.incarnationId } } const adoptedTabIds = [...new Set(validated.map(({ claim }) => claim.tabId))] next.tabsByWorktree[workspace.id] = [...tabsById.values()] const activeTabId = request.activeTabId && tabsById.has(request.activeTabId) ? request.activeTabId : (adoptedTabIds[0] ?? null) const existingGroups = next.tabGroups?.[workspace.id] ?? [] const targetGroupId = (request.activeGroupId && existingGroups.some((group) => group.id === request.activeGroupId) ? request.activeGroupId : existingGroups[0]?.id) ?? request.activeGroupId ?? randomUUID() const proposedGroups = topologyGroups.map((group) => ({ ...group, worktreeId: workspace.id })) const groups = existingGroups.length === 0 && proposedGroups.length > 0 ? proposedGroups : existingGroups.length > 0 ? existingGroups .map((group) => { const proposed = proposedGroups.find((candidate) => candidate.id === group.id) const tabOrder = proposed ? [ ...group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)), ...proposed.tabOrder ] : group.id === targetGroupId && proposedGroups.length === 0 ? [...new Set([...group.tabOrder, ...adoptedTabIds])] : group.tabOrder.filter((tabId) => !adoptedTabIds.includes(tabId)) return { ...group, tabOrder, activeTabId: proposed ? proposed.activeTabId : group.id === targetGroupId && activeTabId ? activeTabId : group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : (tabOrder[0] ?? null), ...(proposed?.recentTabIds ? { recentTabIds: proposed.recentTabIds } : {}) } }) .concat( proposedGroups.filter( (proposed) => !existingGroups.some((group) => group.id === proposed.id) ) ) : [{ id: targetGroupId, worktreeId: workspace.id, activeTabId, tabOrder: adoptedTabIds }] const retainedGroups = groups.filter((group) => group.tabOrder.length > 0) next.tabGroups = { ...next.tabGroups, [workspace.id]: retainedGroups } const mergedGroupLayout = mergeTerminalOrphanGroupLayout({ existingLayout: next.tabGroupLayouts?.[workspace.id], existingGroupIds: existingGroups.map((group) => group.id), proposedLayout: request.topology?.groupLayout, proposedGroupIds: proposedGroups.map((group) => group.id), mergedGroupIds: retainedGroups.map((group) => group.id) }) if (mergedGroupLayout) { next.tabGroupLayouts = { ...next.tabGroupLayouts, [workspace.id]: mergedGroupLayout } } const activeGroup = (request.activeGroupId ? retainedGroups.find( (group) => group.id === request.activeGroupId && (!activeTabId || group.tabOrder.includes(activeTabId)) ) : undefined) ?? retainedGroups.find((group) => activeTabId && group.tabOrder.includes(activeTabId)) ?? retainedGroups[0]! const convergedActiveTabId = activeTabId && activeGroup.tabOrder.includes(activeTabId) ? activeTabId : activeGroup.activeTabId next.activeTabIdByWorktree = { ...next.activeTabIdByWorktree, ...(convergedActiveTabId ? { [workspace.id]: convergedActiveTabId } : {}) } next.activeGroupIdByWorktree = { ...next.activeGroupIdByWorktree, [workspace.id]: activeGroup.id } const persisted = advanceTerminalTopologyRevision(next, workspace.id) try { this.setWorkspaceSessionForWorktree(workspace.id, persisted) store.flushOrThrow() } catch (error) { this.setWorkspaceSessionForWorktree(workspace.id, session) throw error } for (const { claim, pty, paneKey } of validated) { pty.tabId = claim.tabId pty.paneKey = paneKey } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(workspace.id, { force: true, allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) this.notifyMobileSessionTabsChanged(workspace.id) return { adopted: true, topologyRevision: persisted.terminalTopologyRevisionByRepoId?.[repoId] ?? currentRevision + 1, snapshot: this.getTerminalOrphanAdoptionSnapshot(workspace.id) } } private getTerminalOrphanAdoptionSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) return this.getMobileSessionTabsForWorktree(worktreeId) } private buildTerminalVisualLayouts( terminals: RuntimeTerminalSummary[], worktreesById: Map, targetWorktreeId: string | null ): RuntimeTerminalVisualLayout[] { if (terminals.length === 0) { return [] } // Why: the mobile/session snapshot supplies topology, but terminal.list // must print the same handles in both the flat list and visual tree. const summariesByLeafKey = new Map( terminals.map((terminal) => [this.getLeafKey(terminal.tabId, terminal.leafId), terminal]) ) const summariesByWorktree = new Map() for (const terminal of terminals) { const existing = summariesByWorktree.get(terminal.worktreeId) if (existing) { existing.push(terminal) } else { summariesByWorktree.set(terminal.worktreeId, [terminal]) } } const snapshots = targetWorktreeId ? [this.mobileSessionTabsByWorktree.get(targetWorktreeId)].filter( (snapshot): snapshot is RuntimeMobileSessionTabsSnapshot => snapshot !== undefined ) : [...this.mobileSessionTabsByWorktree.values()] const layouts: RuntimeTerminalVisualLayout[] = [] for (const snapshot of snapshots) { const worktreeTerminals = summariesByWorktree.get(snapshot.worktree) if (!worktreeTerminals || worktreeTerminals.length === 0) { continue } const groups = this.buildTerminalVisualGroups(snapshot, summariesByLeafKey) if (groups.length === 0) { continue } const groupsById = new Map( groups .filter((group): group is RuntimeTerminalVisualGroupNode & { groupId: string } => Boolean(group.groupId) ) .map((group) => [group.groupId, group]) ) const root = this.buildTerminalVisualGroupLayout(snapshot.tabGroupLayout, groupsById) ?? groups[0] if (!root) { continue } const worktree = worktreesById.get(snapshot.worktree) layouts.push({ worktreeId: snapshot.worktree, worktreePath: worktree?.path ?? worktreeTerminals[0]?.worktreePath ?? '', root }) } return layouts } private buildTerminalVisualGroups( snapshot: RuntimeMobileSessionTabsSnapshot, summariesByLeafKey: ReadonlyMap ): RuntimeTerminalVisualGroupNode[] { const terminalTabs = snapshot.tabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) if (terminalTabs.length === 0) { return [] } const tabsByParentId = new Map() const parentOrder: string[] = [] for (const tab of terminalTabs) { const existing = tabsByParentId.get(tab.parentTabId) if (existing) { existing.push(tab) } else { parentOrder.push(tab.parentTabId) tabsByParentId.set(tab.parentTabId, [tab]) } } const groupSources = snapshot.tabGroups && snapshot.tabGroups.length > 0 ? snapshot.tabGroups : [{ id: null, activeTabId: snapshot.activeTabId, tabOrder: parentOrder }] return groupSources .map((group): RuntimeTerminalVisualGroupNode | null => { const tabs = group.tabOrder .map((tabId) => { const surfaces = tabsByParentId.get(tabId) ?? terminalTabs.filter((tab) => tab.id === tabId) return this.buildTerminalVisualTab(tabId, surfaces, summariesByLeafKey) }) .filter((tab): tab is RuntimeTerminalVisualTab => tab !== null) if (tabs.length === 0) { return null } return { type: 'group', groupId: group.id, activeTabId: group.activeTabId && tabs.some((tab) => tab.tabId === group.activeTabId) ? group.activeTabId : (tabs[0]?.tabId ?? null), tabs } }) .filter((group): group is RuntimeTerminalVisualGroupNode => group !== null) } private buildTerminalVisualTab( tabId: string, surfaces: RuntimeMobileSessionTerminalTab[], summariesByLeafKey: ReadonlyMap ): RuntimeTerminalVisualTab | null { const firstSurface = surfaces[0] if (!firstSurface) { return null } const parentTabId = firstSurface.parentTabId const requestedActiveLeafId = firstSurface.parentLayout?.activeLeafId ?? surfaces.find((surface) => surface.isActive)?.leafId ?? firstSurface.leafId const root = firstSurface.parentLayout?.root ?? { type: 'leaf' as const, leafId: firstSurface.leafId } const visibleLeafIds = this.collectVisibleTerminalLeafIds(root, parentTabId, summariesByLeafKey) if (visibleLeafIds.length === 0) { return null } const activeLeafId = (requestedActiveLeafId && visibleLeafIds.includes(requestedActiveLeafId) ? requestedActiveLeafId : surfaces.find((surface) => surface.isActive && visibleLeafIds.includes(surface.leafId)) ?.leafId) ?? visibleLeafIds[0]! const panes = this.buildTerminalVisualPane(root, parentTabId, activeLeafId, summariesByLeafKey) if (!panes) { return null } return { tabId: parentTabId || tabId, title: this.tabs.get(parentTabId)?.title ?? firstSurface.title ?? null, activeLeafId, panes } } private collectVisibleTerminalLeafIds( node: TerminalPaneLayoutNode, tabId: string, summariesByLeafKey: ReadonlyMap ): string[] { if (node.type === 'leaf') { return summariesByLeafKey.has(this.getLeafKey(tabId, node.leafId)) ? [node.leafId] : [] } return [ ...this.collectVisibleTerminalLeafIds(node.first, tabId, summariesByLeafKey), ...this.collectVisibleTerminalLeafIds(node.second, tabId, summariesByLeafKey) ] } private buildTerminalVisualPane( node: TerminalPaneLayoutNode, tabId: string, activeLeafId: string | null, summariesByLeafKey: ReadonlyMap ): RuntimeTerminalVisualPaneNode | null { if (node.type === 'leaf') { const summary = summariesByLeafKey.get(this.getLeafKey(tabId, node.leafId)) if (!summary) { return null } return { type: 'terminal', handle: summary.handle, tabId: summary.tabId, leafId: summary.leafId, title: summary.title, connected: summary.connected, active: summary.leafId === activeLeafId } } const first = this.buildTerminalVisualPane(node.first, tabId, activeLeafId, summariesByLeafKey) const second = this.buildTerminalVisualPane( node.second, tabId, activeLeafId, summariesByLeafKey ) if (first && second) { return { type: 'pane-split', direction: node.direction, first, second } } return first ?? second } private buildTerminalVisualGroupLayout( node: TabGroupLayoutNode | null | undefined, groupsById: ReadonlyMap ): RuntimeTerminalVisualLayoutNode | null { if (!node) { return null } if (node.type === 'leaf') { return groupsById.get(node.groupId) ?? null } const first = this.buildTerminalVisualGroupLayout(node.first, groupsById) const second = this.buildTerminalVisualGroupLayout(node.second, groupsById) if (first && second) { return { type: 'split', direction: node.direction, first, second } } return first ?? second } // Why: when --terminal is omitted, the CLI auto-resolves to the active // terminal in the current worktree — matching browser's implicit active tab. async resolveActiveTerminal(worktreeSelector?: string): Promise { if (this.graphStatus !== 'ready') { const targetWorktreeId = worktreeSelector ? (await this.resolveWorktreeSelector(worktreeSelector)).id : null const snapshots = targetWorktreeId ? [this.getMobileSessionTabsForWorktree(targetWorktreeId)] : await this.listAllMobileSessionTabs() for (const snapshot of snapshots) { const activeTerminal = snapshot.tabs.find( (tab) => tab.type === 'terminal' && tab.isActive && tab.status === 'ready' && typeof tab.terminal === 'string' ) if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) { return activeTerminal.terminal } } const listed = await this.listTerminals(worktreeSelector, undefined, { includeVisualLayouts: false }) const first = listed.terminals[0]?.handle if (first) { return first } throw new Error('no_active_terminal') } this.assertGraphReady() const targetWorktreeId = worktreeSelector ? (await this.resolveWorktreeSelector(worktreeSelector)).id : null // Prefer the tab's activeLeafId — this is the pane the user last focused for (const tab of this.tabs.values()) { if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) { continue } if (!tab.activeLeafId) { continue } const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId) const leaf = this.leaves.get(leafKey) if (leaf) { return this.issueHandle(leaf) } } // Fallback: any leaf in the target worktree for (const leaf of this.leaves.values()) { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } return this.issueHandle(leaf) } throw new Error('no_active_terminal') } // Why: orchestration records the pane key as the remint-stable assignee // identity at dispatch time; null (best-effort) rather than throwing so // dispatch still works for handles without a resolvable pane. getTerminalPaneKey(handle: string): string | null { return this.getPaneKeyForTerminalHandle(handle) } getTerminalWorktreeIdForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null return leaf?.worktreeId ?? this.getPtyRecordForPaneKey(paneKey)?.worktreeId ?? null } /** Read-only context of the worktree the user is focused on, for plugin * panels (workspace.readContext). Prefers the persisted session focus and * falls back to the last-focused pane's worktree; null when neither * resolves so panels degrade instead of erroring. */ async resolveActiveWorktreeContext(): Promise<{ worktreeId: string path: string branch: string displayName: string } | null> { let worktreeId = this.store?.getWorkspaceSession?.()?.activeWorktreeId ?? null if (!worktreeId && this.graphStatus === 'ready') { for (const tab of this.tabs.values()) { if (tab.activeLeafId && tab.worktreeId) { worktreeId = tab.worktreeId break } } } if (!worktreeId) { return null } try { const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`) return { worktreeId: resolved.id, path: resolved.git.path, branch: resolved.git.branch, displayName: resolved.displayName } } catch { return null } } getTerminalProcessIncarnation(handle: string): string | null { const live = this.getLivePtyForHandle(handle) const record = live?.record ?? this.handles.get(handle) if (!record?.ptyId) { return null } const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId if (incarnationId) { return `${record.ptyId}:${incarnationId}` } // Why: legacy providers may omit process incarnation; retain the prior restart-degraded fence. return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` } getExactWorkerProviderSession( handle: string, observedAfter: number ): ExactWorkerProviderSession | null { const paneKey = this.getTerminalPaneKey(handle) const processIncarnation = this.getTerminalProcessIncarnation(handle) if (!paneKey || !processIncarnation) { return null } let connectionId: string | null | undefined let launchToken: string | null | undefined try { const ptyId = this.getTerminalAgentStatusPtyId(handle) const pty = this.ptysById.get(ptyId) connectionId = pty?.connectionId ?? null launchToken = pty?.launchToken ?? null } catch { // Exact worker validation rejects this in production; test/legacy providers may not expose PTY metadata. connectionId = undefined launchToken = undefined } return selectExactWorkerProviderSession({ paneKey, processIncarnation, connectionId, launchToken, observedAfter, statuses: this.getAgentStatusSnapshotFn?.() ?? [] }) } validateOrchestrationAgentLauncher(agent: TuiAgent): void { const settings = this.store?.getSettings() if (!settings) { throw new Error('runtime_unavailable') } if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { throw new OrchestrationError( 'agent_unconfigured', `Agent launcher ${agent} is disabled or unavailable.` ) } } resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns // the runtime terminal handle that agents and CLI commands can address. const handle = this.getTerminalHandleForPaneKey(paneKey) if (!handle) { throw new Error('terminal_not_found') } const record = this.handles.get(handle) const parsed = parsePaneKey(paneKey) const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null const pty = this.getPtyRecordForPaneKey(paneKey) const candidateWorktreeIds = [leaf?.worktreeId, pty?.worktreeId].filter( (worktreeId): worktreeId is string => Boolean(worktreeId) ) const worktreeId = candidateWorktreeIds[0] ?? null if ( (candidateWorktreeIds.length > 1 && new Set(candidateWorktreeIds).size > 1) || (expectedWorktreeId && candidateWorktreeIds.some((id) => id !== expectedWorktreeId)) || (expectedWorktreeId && candidateWorktreeIds.length === 0) ) { // Why: pane coordinates restored by a paired client must not cross workspace ownership. throw new Error('terminal_not_found') } return { handle, tabId: parsed?.tabId ?? record?.tabId ?? '', leafId: parsed?.leafId ?? record?.leafId ?? '', ptyId: record?.ptyId ?? null, connected: pty?.connected === true, ...(worktreeId ? { worktreeId } : {}), ...this.getPtyExecutionHostMetadata(record?.ptyId ?? pty?.ptyId ?? null) } } async recoverTerminalPane( paneKey: string, expectedWorktreeId: string, expectedHandle?: string ): Promise { const parsed = parsePaneKey(paneKey) const pty = this.getPtyRecordForPaneKey(paneKey) if ( !parsed || !pty || !expectedHandle || pty.worktreeId !== expectedWorktreeId || this.getPaneKeyForTerminalHandle(expectedHandle) !== paneKey ) { throw new Error('terminal_not_found') } const recoveryKey = `${expectedWorktreeId}\0${paneKey}` const pending = this.terminalPaneRecoveryByIdentity.get(recoveryKey) if (pending) { return pending } if (pty?.connected) { const current = this.resolveTerminalPane(paneKey, expectedWorktreeId) if (expectedHandle === undefined || current.handle !== expectedHandle) { return current } throw new Error('terminal_not_recoverable') } if ( !this.getRecentExpiredSshLease(expectedWorktreeId, parsed.tabId, parsed.leafId, pty.ptyId) ) { // Why: an explicit close leaves a terminated lease; only relay expiry authorizes shell recreation. throw new Error('terminal_not_recoverable') } // Why: disconnected PTYs can reissue handles during graph cleanup; only a connected replacement satisfies the pane CAS. const recovery = this.createTerminal(`id:${expectedWorktreeId}`, { tabId: parsed.tabId, leafId: parsed.leafId, focus: false, // Why: the HUB renderer may publish its exited layout while recovery is in flight; persist the replacement before that stale graph can orphan it. persistHostSessionBinding: true }).then((terminal) => ({ handle: terminal.handle, tabId: parsed.tabId, leafId: parsed.leafId, ptyId: terminal.ptyId ?? null, worktreeId: expectedWorktreeId })) this.terminalPaneRecoveryByIdentity.set(recoveryKey, recovery) const clearRecovery = (): void => { if (this.terminalPaneRecoveryByIdentity.get(recoveryKey) === recovery) { this.terminalPaneRecoveryByIdentity.delete(recoveryKey) } } void recovery.then(clearRecovery, clearRecovery) return recovery } async showTerminal(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { const worktreesById = await this.getResolvedWorktreeMap() const summary = this.buildPtyTerminalSummary(pty.pty, worktreesById) const preview = await this.visibleSnapshotPreview(pty.pty.ptyId, summary.preview) this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) return { ...summary, preview, tabId: pty.pty.tabId ?? pty.record.tabId, leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId, paneRuntimeId: -1, ptyId: pty.pty.ptyId, rendererGraphEpoch: this.rendererGraphEpoch } } const graphEpoch = this.captureReadyGraphEpoch() const worktreesById = await this.getResolvedWorktreeMap() this.assertStableReadyGraph(graphEpoch) const { leaf } = this.getLiveLeafForHandle(handle) const summary = this.buildTerminalSummary(leaf, worktreesById) const preview = leaf.ptyId ? await this.visibleSnapshotPreview(leaf.ptyId, summary.preview) : summary.preview this.assertStableReadyGraph(graphEpoch) if (leaf.ptyId) { this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) } return { ...summary, preview, paneRuntimeId: leaf.paneRuntimeId, ptyId: leaf.ptyId, rendererGraphEpoch: this.rendererGraphEpoch } } async readTerminal( handle: string, opts: { cursor?: number; limit?: number } = {} ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { const read = this.readPtyTerminal(handle, pty.pty, opts) const visibleRead = await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts) this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId) return visibleRead } const { leaf } = this.getLiveLeafForHandle(handle) const read = readTerminalTail({ handle, status: getTerminalState(leaf), previewLines: leaf.tailBuffer, completedLines: leaf.tailTranscriptBuffer, partialLine: leaf.tailPartialLine, completedLineCount: leaf.tailLinesTotal, bufferTruncated: leaf.tailTruncated, cursor: opts.cursor, limit: opts.limit }) if (!leaf.ptyId) { return read } const visibleRead = await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts) this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId) return visibleRead } // Why a cache: leaf-branch sends may arrive per keystroke; one proven-absent // verdict per ptyId serves the burst instead of a probe round-trip each call. private readonly provenAbsentLeafPtyVerdicts = new Map() private readonly leafPtyAbsenceProbes = new Map>() // Why: probe dedupe shares one promise across callers, but each caller's // continuation would re-deliver the same unread rows; arm one per pty. private readonly probeDeferredDeliveryPtyIds = new Set() private controllerKnowsPtyIsLive(ptyId: string): boolean { try { return this.ptyController?.hasPty?.(ptyId) === true } catch { // Why: liveness lookup failures are doubt; doubt never gates a write. return false } } /** True only on controller-proven absence; live, unknown, and probe errors all answer false. */ private isLeafPtyProvenAbsent(ptyId: string): Promise { // Why hasPty and not ptysById: graph sync mirrors a connected record for // every leaf ptyId — including a prior process's — so runtime records can't // distinguish live from stale. The controller's exact-id hasPty is the // provider's own synchronous inventory: a known id is alive, skip probing // and supersede any cached verdict (the id came back). if (this.controllerKnowsPtyIsLive(ptyId)) { this.provenAbsentLeafPtyVerdicts.delete(ptyId) return Promise.resolve(false) } const verdictAt = this.provenAbsentLeafPtyVerdicts.get(ptyId) if (verdictAt !== undefined) { if (Date.now() - verdictAt < PROVEN_ABSENT_LEAF_PTY_TTL_MS) { return Promise.resolve(true) } this.provenAbsentLeafPtyVerdicts.delete(ptyId) } const probeLiveness = this.ptyController?.probePtyLiveness?.bind(this.ptyController) if (!probeLiveness) { return Promise.resolve(false) } const inFlight = this.leafPtyAbsenceProbes.get(ptyId) if (inFlight) { return inFlight } const probe = (async () => { try { if ((await probeLiveness(ptyId)) !== false) { return false } this.provenAbsentLeafPtyVerdicts.set(ptyId, Date.now()) return true } catch { // Why: a failed probe is unknown, and unknown never rejects a write. return false } finally { this.leafPtyAbsenceProbes.delete(ptyId) } })() this.leafPtyAbsenceProbes.set(ptyId, probe) return probe } async sendTerminal( handle: string, action: { text?: string enter?: boolean interrupt?: boolean }, options: { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_not_writable') } const payload = buildSendPayload(action) if (payload === null) { throw new Error('invalid_terminal_send') } await assertTerminalInputWithinLimitWithYield(action.text) await this.writeTerminalAction(pty.pty.ptyId, action, payload, options) return { handle, accepted: true, bytesWritten: Buffer.byteLength(payload, 'utf8') } } const { leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') } const payload = buildSendPayload(action) if (payload === null) { throw new Error('invalid_terminal_send') } await assertTerminalInputWithinLimitWithYield(action.text) // Why: leaf.writable mirrors the renderer graph, which can still answer for // a prior process's ptyId — and provider writes to unknown ids are accepted // no-ops. Only controller-proven absence rejects; unknown proceeds (a // restored daemon session takes writes before its pane remounts). if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { throw new Error('terminal_not_writable') } await this.writeTerminalAction(leaf.ptyId, action, payload, options) return { handle, accepted: true, bytesWritten: Buffer.byteLength(payload, 'utf8') } } async sendTerminalAgentPrompt( handle: string, prompt: string, options: { beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { const payload = buildAgentPromptPasteBytes(prompt) const bytesWritten = Buffer.byteLength(`${payload}${AGENT_PROMPT_SUBMIT}`, 'utf8') const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_not_writable') } await assertTerminalInputWithinLimitWithYield(payload) await this.writeTerminalAgentPrompt(pty.pty.ptyId, payload, options) return { handle, accepted: true, bytesWritten } } const { leaf } = this.getLiveLeafForHandle(handle) if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') } await assertTerminalInputWithinLimitWithYield(payload) // Why: same absence gate as sendTerminal — a stale graph mirror must not // accept a prompt into a void; unknown liveness still proceeds. if (await this.isLeafPtyProvenAbsent(leaf.ptyId)) { throw new Error('terminal_not_writable') } await this.writeTerminalAgentPrompt(leaf.ptyId, payload, options) return { handle, accepted: true, bytesWritten } } async getTerminalAgentStatus(handle: string): Promise { const ptyId = this.getTerminalAgentStatusPtyId(handle) const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && terminal.titleStatus !== null && terminal.titleStatus !== 'permission' if (terminal.titleStatus === 'permission' && terminal.titleStatusIsLive) { return { handle, isRunningAgent: true, status: 'permission' } } if ( blockedByWaitText && !liveTitleClearsBlockedText && (!explicitStatus || explicitStatus.status === 'permission' || (terminal.waitBlockedAt !== null && terminal.waitBlockedAt >= explicitStatus.updatedAt)) ) { return { handle, isRunningAgent: true, status: 'permission' } } if (explicitStatus) { // Why: permission titles can linger after hooks report the agent resumed. // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = !terminalTitleBlocksExplicitAgentStatus(terminal.title) && !(await this.terminalHasShellForegroundProcess(handle, ptyId)) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, isRunningAgent, status: isRunningAgent ? explicitStatus.status : null } } if (terminal.titleStatus) { return { handle, isRunningAgent: true, status: terminal.titleStatus } } const isRunningAgent = await this.isTerminalRunningAgent(handle) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, isRunningAgent, status: null } } private getTerminalAgentStatusPtyId(handle: string): string { const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_gone') } return pty.pty.ptyId } const { leaf } = this.getLiveLeafForHandle(handle) if (getTerminalState(leaf) !== 'running') { throw new Error('terminal_exited') } if (!leaf.ptyId) { throw new Error('terminal_gone') } return leaf.ptyId } private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { return } // Why: delayed process evidence belongs only to the PTY that started the // read, while callers still rely on the established stale-handle contract. throw new Error('terminal_handle_stale') } private getTerminalAgentStatusSnapshot( handle: string, expectedPtyId: string ): { waitText: string waitBlockedAt: number | null title: string | null titleStatus: AgentStatus | null titleStatusIsLive: boolean } { const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { throw new Error('terminal_not_writable') } const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) const leafTitle = leaf ? getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } ) : null const ptyTitle = leafTitle ?? getLatestAgentCandidateTitleInfo( { title: pty.pty.title, updatedAt: pty.pty.titleUpdatedAt }, { title: pty.pty.lastOscTitle, updatedAt: pty.pty.lastOscTitleAt } ) const waitText = buildTerminalWaitText( pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview ) return { waitText, waitBlockedAt: pty.pty.waitBlockedAt, title: ptyTitle?.title ?? null, titleStatus: ptyTitle ? detectAgentStatusFromTitle(ptyTitle.title) : pty.pty.lastAgentStatus, titleStatusIsLive: ptyTitle !== null } } const { leaf } = this.getLiveLeafForHandle(handle) if (getTerminalState(leaf) !== 'running') { throw new Error('terminal_exited') } if (!leaf.ptyId) { throw new Error('terminal_gone') } if (leaf.ptyId !== expectedPtyId) { throw new Error('terminal_not_writable') } const title = getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, { title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 } ) return { waitText: buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview), waitBlockedAt: leaf.waitBlockedAt, title: title?.title ?? null, titleStatus: title ? detectAgentStatusFromTitle(title.title) : leaf.lastAgentStatus, titleStatusIsLive: (title?.updatedAt ?? 0) > 0 } } private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return false } this.assertTerminalAgentStatusPtyBinding(handle, ptyId) if (!foregroundProcess || !isShellProcess(foregroundProcess)) { return false } const confirmationController = this.ptyController if (!confirmationController?.confirmForegroundProcess) { return true } let confirmedProcess: string | null try { confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) } catch { this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return true } this.assertTerminalAgentStatusPtyBinding(handle, ptyId) // Why: hook identity is generic; strong provider evidence only needs to // prove that some recognized agent still owns this exact PTY. return recognizeAgentProcess(confirmedProcess) === null } private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( pty: RuntimePtyWorktreeRecord, title: string ): boolean { return ( !pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title) ) } /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. */ private refreshPtyForegroundAgent(ptyId: string): void { void this.refreshPtyForegroundAgentFromController(ptyId) } private getPendingForegroundAgentRefreshForTitle( ptyId: string, titleObservedAt: number ): Promise | undefined { if (!this.ptyForegroundAgentRefreshes.has(ptyId)) { return undefined } return this.refreshPtyForegroundAgentFromController(ptyId, { afterTitleObservation: titleObservedAt }) } private delayPtyBackedMobileSnapshotForForegroundAgent( ptyId: string, titleObservedAt: number, foregroundRefresh: Promise ): void { this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt) void foregroundRefresh.then((foregroundAgentChanged) => { if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) { return } this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) if (!foregroundAgentChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } }) } /** * Deduplicates and manages in-flight foreground agent refresh queries * for a specific PTY. */ private refreshPtyForegroundAgentFromController( ptyId: string, options: { afterTitleObservation?: number } = {} ): Promise { const startedAfterTitleObservation = options.afterTitleObservation ?? 0 const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId) if (pendingRefresh) { pendingRefresh.requestedAfterTitleObservation = Math.max( pendingRefresh.requestedAfterTitleObservation, startedAfterTitleObservation ) return pendingRefresh.promise } const entry: PtyForegroundAgentRefresh = { promise: Promise.resolve(false), startedAfterTitleObservation, requestedAfterTitleObservation: startedAfterTitleObservation } const refresh = (async (): Promise => { while (true) { entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId) if ( foregroundAgentChanged || entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation ) { return foregroundAgentChanged } } })().finally(() => { if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) { this.ptyForegroundAgentRefreshes.delete(ptyId) } }) entry.promise = refresh this.ptyForegroundAgentRefreshes.set(ptyId, entry) return refresh } /** * Queries the PTY controller for the active foreground process, identifies if it * is a recognized agent, and updates the PTY's foreground agent state if changed. */ private async loadPtyForegroundAgentFromController(ptyId: string): Promise { if (!this.ptyController) { return false } const pty = this.ptysById.get(ptyId) if (!pty?.connected) { return false } // Why: foregroundAgent is only consulted as the owner fallback when // launchAgent is unknown, so a known launchAgent makes the relay // getForegroundProcess round-trip pure waste (covers all launched agents). if (pty.launchAgent) { return false } let foregroundProcess: string | null try { foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { return false } const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null if (pty.foregroundAgent === foregroundAgent) { return false } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) return true } private getFreshExplicitAgentStatusForHandle(handle: string): { status: NonNullable updatedAt: number } | null { const paneKey = this.getPaneKeyForTerminalHandle(handle) const now = Date.now() let bestStatus: NonNullable | null = null let bestUpdatedAt = -1 const consider = ( state: AgentStatusEntry['state'] | undefined, updatedAt: number | null | undefined, restoredUnconfirmed = false ): void => { if (!state || restoredUnconfirmed) { return } if (typeof updatedAt !== 'number' || now - updatedAt > AGENT_STATUS_STALE_AFTER_MS) { return } const status = mapExplicitAgentStateToRuntimeTerminalStatus(state) // Why: older retained permission rows can remain visible after the agent // resumes. Prefer the newest explicit state; only let permission win ties. if (updatedAt > bestUpdatedAt || (updatedAt === bestUpdatedAt && status === 'permission')) { bestStatus = status bestUpdatedAt = updatedAt } } if (paneKey) { const retained = this.latestAgentStatusByPaneKey.get(paneKey) consider(retained?.payload.state, retained?.updatedAt) } for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { if (entry.terminalHandle !== handle && (!paneKey || entry.paneKey !== paneKey)) { continue } consider(entry.state, entry.receivedAt, entry.restoredUnconfirmed) } return bestStatus ? { status: bestStatus, updatedAt: bestUpdatedAt } : null } private async writeTerminalAction( ptyId: string, action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, options: { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. const hasText = typeof action.text === 'string' && action.text.length > 0 const hasSuffix = action.enter || action.interrupt if (hasText) { await this.writeTerminalInputChunks(ptyId, action.text!, options) } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') if (hasText) { await new Promise((resolve) => setTimeout(resolve, 500)) } try { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) } throw error } const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } await options.afterWrite?.(ptyId) return } if (hasText) { return } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) } private async writeTerminalInputChunks( ptyId: string, text: string, options: { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise } = {} ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { throw new Error('terminal_not_writable') } await options.afterWrite?.(ptyId) chunk = chunks.next() if (!chunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) } } } private async writeTerminalAgentPrompt( ptyId: string, pastePayload: string, options: { beforeWrite?: (ptyId: string) => void | Promise suffixFailureError?: string } = {} ): Promise { let wrotePasteBytes = false let completedPaste = false try { const chunks = iterateTerminalInputChunks(pastePayload) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { throw new Error('terminal_not_writable') } wrotePasteBytes = true chunk = chunks.next() if (!chunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) } } completedPaste = true } catch (error) { if (wrotePasteBytes && !completedPaste) { this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) } throw error } await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS)) try { await options.beforeWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) } throw error } const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } async waitForTerminal( handle: string, options?: { condition?: RuntimeTerminalWaitCondition timeoutMs?: number signal?: AbortSignal } ): Promise { const condition = options?.condition ?? 'exit' const pty = this.getLivePtyForHandle(handle) if (pty) { if (condition === 'exit' && !pty.pty.connected) { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } const ptyWaitText = buildTerminalWaitText( pty.pty.tailBuffer, pty.pty.tailPartialLine, pty.pty.preview ) const ptyBlockedReason = detectTerminalWaitBlockedReason(ptyWaitText) if (condition === 'tui-idle' && ptyBlockedReason) { return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) } if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } if ( condition === 'tui-idle' && (this.getAdoptedPtyExplicitIdleStatus(pty.pty) === 'idle' || isKnownReadyPromptPreview(ptyWaitText)) ) { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } return await new Promise((resolve, reject) => { const effectiveTimeoutMs = typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : condition === 'tui-idle' ? TUI_IDLE_DEFAULT_TIMEOUT_MS : 0 const waiter: TerminalWaiter = { handle, condition, resolve, reject, timeout: null, pollInterval: null, abortCleanup: null } if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { reject(new Error('request_aborted')) return } if (effectiveTimeoutMs > 0) { waiter.timeout = setTimeout(() => { this.removeWaiter(waiter) reject(new Error('timeout')) }, effectiveTimeoutMs) } let waiters = this.waitersByHandle.get(handle) if (!waiters) { waiters = new Set() this.waitersByHandle.set(handle, waiters) } waiters.add(waiter) const live = this.getLivePtyForHandle(handle) if (!live) { this.removeWaiter(waiter) reject(new Error('terminal_handle_stale')) } else if (condition === 'exit' && !live.pty.connected) { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else if (condition === 'tui-idle') { const livePtyWaitText = buildTerminalWaitText( live.pty.tailBuffer, live.pty.tailPartialLine, live.pty.preview ) const blockedReason = detectTerminalWaitBlockedReason(livePtyWaitText) if (blockedReason) { this.resolveWaiter( waiter, buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) ) } else if (live.pty.lastAgentStatus === 'idle') { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else if ( this.getAdoptedPtyExplicitIdleStatus(live.pty) === 'idle' || isKnownReadyPromptPreview(livePtyWaitText) ) { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else { this.startPtyTuiIdleFallbackPoll(waiter, live.pty) } } }) } const { leaf } = this.getLiveLeafForHandle(handle) if (condition === 'exit' && getTerminalState(leaf) === 'exited') { return buildTerminalWaitResult(handle, condition, leaf) } const leafWaitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) const leafBlockedReason = detectTerminalWaitBlockedReason(leafWaitText) if (condition === 'tui-idle' && leafBlockedReason) { return buildTerminalWaitBlockedResult(handle, condition, leaf, leafBlockedReason) } // Why: if the agent already transitioned to idle (or permission) before the // waiter was registered, resolve immediately. This uses the same OSC title // detection that powers the renderer's "Task complete" notifications. // Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the // agent is blocked on user approval, not finished with its task. if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { return buildTerminalWaitResult(handle, condition, leaf) } if (condition === 'tui-idle') { const fastPathTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title if ( (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || isKnownReadyPromptPreview(leafWaitText) ) { return buildTerminalWaitResult(handle, condition, leaf) } } return await new Promise((resolve, reject) => { // Why: tui-idle depends on OSC title transitions from a recognized agent. // If no agent is detected, the waiter would hang forever. Enforce a default // timeout so unsupported CLIs fail predictably instead of silently blocking. const effectiveTimeoutMs = typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : condition === 'tui-idle' ? TUI_IDLE_DEFAULT_TIMEOUT_MS : 0 const waiter: TerminalWaiter = { handle, condition, resolve, reject, timeout: null, pollInterval: null, abortCleanup: null } if (!this.bindTerminalWaiterAbort(waiter, options?.signal)) { reject(new Error('request_aborted')) return } if (effectiveTimeoutMs > 0) { waiter.timeout = setTimeout(() => { this.removeWaiter(waiter) reject(new Error('timeout')) }, effectiveTimeoutMs) } let waiters = this.waitersByHandle.get(handle) if (!waiters) { waiters = new Set() this.waitersByHandle.set(handle, waiters) } waiters.add(waiter) // Why: the handle may go stale or exit in the small gap between the first // validation and waiter registration. Re-checking here keeps wait --for // exit honest instead of hanging on a terminal that already changed. try { const live = this.getLiveLeafForHandle(handle) if (getTerminalState(live.leaf) === 'exited') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } else if (condition === 'tui-idle') { const liveLeafWaitText = buildTerminalWaitText( live.leaf.tailBuffer, live.leaf.tailPartialLine, live.leaf.preview ) const blockedReason = detectTerminalWaitBlockedReason(liveLeafWaitText) if (blockedReason) { this.resolveWaiter( waiter, buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) ) } else if (live.leaf.lastAgentStatus === 'idle') { // Why: don't clear lastAgentStatus here. It's a factual record of the // last detected OSC state, not a one-shot signal. Clearing it causes // subsequent tui-idle waiters to hang even though the agent is idle — // the first waiter consumes the status and all later ones see null. this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } else { // Why: renderer-synced previews can show a known ready prompt even // while the last OSC title is still "working"; keep polling the // preview/title until the waiter resolves or hits its timeout. const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title if ( (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || isKnownReadyPromptPreview(liveLeafWaitText) ) { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } else { this.startTuiIdleFallbackPoll(waiter, live.leaf) } } } } catch (error) { this.removeWaiter(waiter) reject(error instanceof Error ? error : new Error(String(error))) } }) } async waitForSetupTerminalCompletion(handle: string): Promise<{ exitCode: number | null }> { const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId if (!ptyId) { throw new Error('terminal_handle_stale') } const completionToken = this.setupCompletionTokenByPtyId.get(ptyId) const exitAbort = new AbortController() return await new Promise<{ exitCode: number | null }>((resolve, reject) => { let settled = false let unsubscribe: (() => void) | null = null const cleanup = (): void => { unsubscribe?.() exitAbort.abort() } const finish = (exitCode: number | null): void => { if (settled) { return } settled = true cleanup() this.setupCompletionTokenByPtyId.delete(ptyId) resolve({ exitCode }) } const fail = (error: unknown): void => { if (settled) { return } settled = true cleanup() reject(error) } const scanner = completionToken ? createSetupCompletionScanner(completionToken, finish) : null if (scanner) { unsubscribe = this.subscribeToTerminalData(ptyId, scanner.scan) } // Why: setup can finish before the observer is registered on fast local worktrees. const replay = this.recentPtyOutputById.get(ptyId)?.read() if (scanner && replay) { scanner.scan(replay) } if (!settled) { void this.waitForTerminal(handle, { condition: 'exit', signal: exitAbort.signal }) .then((wait) => { if (wait.satisfied && wait.condition === 'exit' && wait.status === 'exited') { finish(wait.exitCode) } }) .catch(fail) } }) } async getWorktreePs(limit = DEFAULT_WORKTREE_PS_LIMIT): Promise<{ worktrees: RuntimeWorktreePsSummary[] totalCount: number truncated: boolean }> { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot() const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) => this.isRuntimeWorktreeVisible(worktree) ) // Why: worktree.ps backs the mobile sidebar, so it must use the same // host-owned imported-worktree visibility gate as worktree.list/desktop. const freshPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId const summaries = new Map() // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), // matching how the renderer's fetchPRForBranch stores entries. We look up cached // PR info so mobile clients can group worktrees by PR state without making // expensive `gh` CLI calls. Falls back to meta.linkedPR if no cache entry exists. const ghCache = this.store?.getGitHubCache?.() for (const worktree of resolvedWorktrees) { const meta = this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id] const repo = repoById.get(worktree.repoId) let linkedPR: { number: number; state: string } | null = null const branch = worktree.branch.replace(/^refs\/heads\//, '') if (branch && ghCache) { // Why: the renderer keys the PR cache by `repoId::branch` (getGitHubPRCacheKey // prefers repo.id over repo.path), so read by id first and fall back to path // for legacy/path-keyed entries. Reading only by path missed every cached // entry, leaving mobile's linked-PR badge stuck on the 'unknown' fallback. const cached = (repo?.id ? ghCache.pr[`${repo.id}::${branch}`] : undefined) ?? (repo?.path ? ghCache.pr[`${repo.path}::${branch}`] : undefined) if (cached?.data) { linkedPR = { number: cached.data.number, state: cached.data.state } } } if (!linkedPR && meta?.linkedPR != null) { linkedPR = { number: meta.linkedPR, state: 'unknown' } } const terminalPlatform = platformByRepoId.get(worktree.repoId) ?? process.platform // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, // so a stale same-path entry would nest replacement checkouts under old parents. const lineage = worktree.lineage summaries.set(worktree.id, { // Why: mobile mirrors desktop workspace grouping/order from persisted // metadata, while older runtimes may not have hydrated every field yet. workspaceKind: 'git', worktreeId: worktree.id, repoId: worktree.repoId, ...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}), terminalPlatform, repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), ...(lineage?.worktreeInstanceId !== undefined ? { lineageWorktreeInstanceId: lineage.worktreeInstanceId } : {}), ...(lineage?.parentWorktreeInstanceId !== undefined ? { parentWorktreeInstanceId: lineage.parentWorktreeInstanceId } : {}), parentWorktreeId: worktree.parentWorktreeId, childWorktreeIds: worktree.childWorktreeIds, displayName: worktree.displayName, workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, sortOrder: meta?.sortOrder ?? 0, ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), linkedIssue: worktree.linkedIssue, linkedPR, linkedLinearIssue: meta?.linkedLinearIssue ?? null, linkedGitLabMR: meta?.linkedGitLabMR ?? null, linkedGitLabIssue: meta?.linkedGitLabIssue ?? null, comment: meta?.comment ?? '', isPinned: meta?.isPinned ?? false, isActive: false, unread: meta?.isUnread ?? false, liveTerminalCount: 0, hasAttachedPty: false, lastOutputAt: null, preview: '', status: 'inactive', agents: [] }) } const projectGroupById = new Map( (this.store?.getProjectGroups?.() ?? []).map((group) => [group.id, group]) ) for (const folderWorkspace of this.store?.getFolderWorkspaces?.() ?? []) { const projectGroup = projectGroupById.get(folderWorkspace.projectGroupId) if (!projectGroup?.parentPath) { continue } const worktree = folderWorkspaceToWorktree(folderWorkspace) summaries.set(worktree.id, { // Why: folder workspaces use the same mobile grouping/order contract as // git worktrees, but legacy records may be missing order metadata. workspaceKind: 'folder-workspace', worktreeId: worktree.id, repoId: worktree.repoId, repo: projectGroup.name, path: worktree.path, branch: worktree.branch, isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), parentWorktreeId: null, childWorktreeIds: [], displayName: worktree.displayName, workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, sortOrder: worktree.sortOrder ?? 0, ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), lastActivityAt: worktree.lastActivityAt, ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), linkedIssue: worktree.linkedIssue ?? null, linkedPR: null, linkedLinearIssue: worktree.linkedLinearIssue ?? null, linkedGitLabMR: worktree.linkedGitLabMR ?? null, linkedGitLabIssue: worktree.linkedGitLabIssue ?? null, comment: worktree.comment, isPinned: worktree.isPinned, isActive: false, unread: worktree.isUnread, liveTerminalCount: 0, hasAttachedPty: false, lastOutputAt: null, preview: '', status: 'inactive', agents: [] }) } const runtimeWorktreeSummaryPathIndex = buildRuntimeWorktreeSummaryPathIndex( summaries, resolvedWorktrees, platformByRepoId ) const missingRuntimeWorktreeIds = new Set() const countedPtyIds = new Set() const session = this.store?.getWorkspaceSession?.() const savedTabOwnerById = new Map() for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { for (const tab of tabs) { savedTabOwnerById.set(tab.id, { worktreeId, title: tab.title }) } } const savedLayoutTabIdByPtyId = new Map() for (const [tabId, layout] of Object.entries(session?.terminalLayoutsByTabId ?? {})) { for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { if (ptyId) { savedLayoutTabIdByPtyId.set(ptyId, tabId) } } } for (const leaf of this.leaves.values()) { if ( !leaf.ptyId || !leaf.connected || (freshPtyLiveness !== null && !freshPtyLiveness.has(leaf.ptyId)) ) { continue } const freshPtyOwner = this.ptysById.get(leaf.ptyId) if ( freshPtyLiveness !== null && freshPtyOwner?.connected && !runtimeWorktreeIdsEqual(freshPtyOwner.worktreeId, leaf.worktreeId) ) { // Why: provider/persisted ownership is fresher than a renderer leaf left behind by graph migration or another client. continue } const summary = this.getSummaryForRuntimeWorktreeId( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, leaf.worktreeId ) if (!summary) { continue } countedPtyIds.add(leaf.ptyId) summary.hasHostSidebarActivity = true const previousLastOutputAt = summary.lastOutputAt summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) summary.status = mergeWorktreeStatus( summary.status, getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) ) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) ) { summary.preview = leaf.preview } } for (const pty of this.ptysById.values()) { if ( !pty.connected || countedPtyIds.has(pty.ptyId) || (freshPtyLiveness !== null && !freshPtyLiveness.has(pty.ptyId)) ) { continue } const persistedTabId = savedLayoutTabIdByPtyId.get(pty.ptyId) let owner = persistedTabId ? savedTabOwnerById.get(persistedTabId) : undefined if (freshPtyLiveness !== null) { // Why: refresh resolved provider/migration ownership; stale persisted tabs may supply a title but cannot reassign a live PTY. owner = { worktreeId: pty.worktreeId, title: owner?.title ?? getLatestPtyTitle(pty) ?? '' } } if (!owner && persistedTabId && pty.tabId === persistedTabId) { owner = { worktreeId: pty.worktreeId, title: getLatestPtyTitle(pty) ?? '' } } const parsedPaneKey = parsePaneKey(pty.paneKey ?? '') const hasExplicitRuntimeOwner = pty.tabId !== null && parsedPaneKey?.tabId === pty.tabId && parsedPaneKey.leafId.length > 0 const savedTabOwner = pty.tabId ? savedTabOwnerById.get(pty.tabId) : undefined const hasSavedLayout = pty.tabId !== null && Object.hasOwn(session?.terminalLayoutsByTabId ?? {}, pty.tabId) if (!owner && hasExplicitRuntimeOwner && !hasSavedLayout) { owner = { worktreeId: savedTabOwner?.worktreeId ?? pty.worktreeId, title: savedTabOwner?.title ?? getLatestPtyTitle(pty) ?? '' } } if (!owner) { // Why: provider existence alone cannot attribute a reused or unbound PTY to a workspace. continue } const summary = this.getSummaryForRuntimeWorktreeId( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, owner.worktreeId ) if (!summary) { continue } const previousLastOutputAt = summary.lastOutputAt summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.hasHostSidebarActivity = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) summary.status = mergeWorktreeStatus( summary.status, getSavedTabWorktreeStatus(owner.title, true) ) if ( pty.preview && (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) ) { summary.preview = pty.preview } } const mirroredWorktreeIdByTabId = new Map() const sessionsByHostId = new Map() for (const summary of summaries.values()) { const repo = repoById.get(summary.repoId) const hostId = repo ? getRepoExecutionHostId(repo) : 'local' const session = this.store?.getWorkspaceSession?.(hostId) if (session) { sessionsByHostId.set(hostId, session) } } for (const session of sessionsByHostId.values()) { for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) { for (const tab of tabs) { mirroredWorktreeIdByTabId.set(tab.id, worktreeId) } if (tabs.length === 0) { continue } const summary = this.getSummaryForRuntimeWorktreeId( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, worktreeId ) if (!summary) { continue } if (tabs.some((tab) => tab.ptyId !== null && this.ptysById.get(tab.ptyId)?.connected)) { summary.hasHostSidebarActivity = true } } for (const [worktreeId, tabs] of Object.entries(session.browserTabsByWorktree ?? {})) { if (tabs.length === 0) { continue } const summary = this.getSummaryForRuntimeWorktreeId( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, worktreeId ) if (summary) { summary.hasHostSidebarActivity = true } } if (session.activeWorktreeId) { const activeSummary = this.getSummaryForRuntimeWorktreeId( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, session.activeWorktreeId ) if (activeSummary) { activeSummary.isActive = true } } } // Why: a live renderer graph may precede persistence, but persisted tab // ownership wins when an automatic workspace rename has already rekeyed it. for (const [tabId, tab] of this.tabs) { if (!mirroredWorktreeIdByTabId.has(tabId)) { mirroredWorktreeIdByTabId.set(tabId, tab.worktreeId) } } // Why: a connected PTY proves a pane is still live even when its tab has // already left every session record (daemon-held terminals, graph gaps). // Deliberately trusts the optimistic connected flag (no freshPtyLiveness // gate, unlike the count loops above): evidence only KEEPS rows. const connectedPtyEvidence = { tabIds: new Set(), paneKeys: new Set(), ptyIds: new Set() } for (const pty of this.ptysById.values()) { if (!pty.connected) { continue } connectedPtyEvidence.ptyIds.add(pty.ptyId) if (pty.tabId) { connectedPtyEvidence.tabIds.add(pty.tabId) } if (pty.paneKey) { connectedPtyEvidence.paneKeys.add(pty.paneKey) } } this.attachAgentRowsToSummaries( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId, connectedPtyEvidence ) const sorted = [...summaries.values()].sort(compareWorktreePs) return { worktrees: sorted.slice(0, limit), totalCount: sorted.length, truncated: sorted.length > limit } } // Why: maps the retained per-pane agent snapshots into each worktree's inline // agent list, mirroring the desktop sidebar. Lineage parent is resolved from // the orchestration db (paneKey-keyed), not the OSC payload, since spawn // hierarchy is pane-level state tracked separately from terminal output. private attachAgentRowsToSummaries( summaries: Map, runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds: Set, mirroredWorktreeIdByTabId: ReadonlyMap, connectedPtyEvidence: { tabIds: ReadonlySet paneKeys: ReadonlySet ptyIds: ReadonlySet } ): void { // Why: most agents report via hooks (agent-hooks/server), not OSC, so the // hook snapshot is the primary source — same one the desktop sidebar reads. // OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey. const rowSources = new Map< string, { paneKey: string ptyId?: string tabId?: string worktreeId?: string connectionId: string | null state: ParsedAgentStatusPayload['state'] agentType: string | null prompt: string lastAssistantMessage: string | null toolName: string | null toolInput: string | null interrupted: boolean stateStartedAt: number updatedAt: number restoredUnconfirmed?: boolean } >() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { const { payload } = snapshot rowSources.set(snapshot.paneKey, { paneKey: snapshot.paneKey, ptyId: snapshot.ptyId, tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, connectionId: snapshot.connectionId, state: payload.state, agentType: payload.agentType ?? null, prompt: payload.prompt, lastAssistantMessage: payload.lastAssistantMessage ?? null, toolName: payload.toolName ?? null, toolInput: payload.toolInput ?? null, interrupted: payload.interrupted ?? false, stateStartedAt: snapshot.stateStartedAt, updatedAt: snapshot.updatedAt }) } for (const entry of this.getAgentStatusSnapshotFn?.() ?? []) { const existing = rowSources.get(entry.paneKey) // Why: hook rows win ties, but an older cached hook must not replace a // fresh OSC status and make a running mobile workspace look inactive. if (existing && existing.updatedAt > entry.receivedAt) { continue } rowSources.set(entry.paneKey, { paneKey: entry.paneKey, // Hook payloads carry no ptyId; keep the OSC-observed one so the // connected-PTY rescue survives a hook row winning the freshness race. ptyId: existing?.ptyId, tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, state: entry.state, agentType: entry.agentType ?? null, prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, toolName: entry.toolName ?? null, toolInput: entry.toolInput ?? null, interrupted: entry.interrupted ?? false, stateStartedAt: entry.stateStartedAt, updatedAt: entry.receivedAt, ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}) }) } if (rowSources.size === 0) { return } const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() const rowsByWorktree = new Map() const now = Date.now() for (const src of rowSources.values()) { // Why: hooks retain launch-time attribution across automatic workspace // renames; the tab's current mirrored owner is authoritative when present. // Legacy numeric pane keys (non-UUID leaves) still name a real tab, so // parse them too — otherwise their rows would bypass the stale filter. const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId ?? parseLegacyNumericPaneKey(src.paneKey)?.tabId const mirroredWorktreeId = tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined if ( tabId !== undefined && mirroredWorktreeId === undefined && (src.connectionId === null || isWslHookRelayConnectionId(src.connectionId)) && !connectedPtyEvidence.tabIds.has(tabId) && !connectedPtyEvidence.paneKeys.has(src.paneKey) && (src.ptyId === undefined || !connectedPtyEvidence.ptyIds.has(src.ptyId)) ) { // Why: hook snapshots hydrate from last-status.json for days, so a row // from a local or WSL-relayed pane whose tab left every session and the // live graph, with no connected PTY, is retained history — surfacing it // resurrects closed agents on mobile (#6072). SSH rows are exempt (their // tabs may exist only remotely), as are rows with no resolvable tabId // (staleness unprovable). Session tabs count as existence: headless // serve has no renderer graph, and session.tabs.list serves them. continue } const worktreeId = mirroredWorktreeId ?? src.worktreeId if (!worktreeId) { continue } const summary = this.getSummaryForRuntimeWorktreeId( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, worktreeId ) if (!summary) { continue } const taskTitle = orchestrationByPaneKey?.[src.paneKey]?.taskTitle ?? null const displayName = orchestrationByPaneKey?.[src.paneKey]?.displayName ?? null const row: RuntimeWorktreeAgentRow = { paneKey: src.paneKey, parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null, state: src.state, agentType: src.agentType, prompt: src.prompt, taskTitle, displayName, lastAssistantMessage: src.lastAssistantMessage, toolName: src.toolName, toolInput: src.toolInput, interrupted: src.interrupted, stateStartedAt: src.stateStartedAt, updatedAt: src.updatedAt, ...(src.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}) } // Why: SSH/runtime projections can spell an equivalent path differently; // bucket by the canonical summary id so mobile keeps the agent activity. const rows = rowsByWorktree.get(summary.worktreeId) if (rows) { rows.push(row) } else { rowsByWorktree.set(summary.worktreeId, [row]) } } for (const [worktreeId, rows] of rowsByWorktree) { // Oldest-started first, matching the desktop dashboard's start-order sort. rows.sort((a, b) => a.stateStartedAt - b.stateStartedAt) const summary = summaries.get(worktreeId) if (summary) { summary.agents = rows for (const row of rows) { if (!isFreshNonDoneAgentStatus(row, now)) { continue } // Why: worktree.ps is mobile's host-sidebar parity source, so a live // agent must survive the same temporary PTY gaps as desktop. summary.hasHostSidebarActivity = true summary.status = mergeWorktreeStatus( summary.status, row.state === 'working' ? 'working' : 'permission' ) } } } } listRepos(): Repo[] { return this.store?.getRepos() ?? [] } enrichMissingRepoGitRemoteIdentities(): void { if (!this.store) { return } enrichMissingRepoGitRemoteIdentities(this.store, { onChanged: () => { this.invalidateResolvedWorktreeCache() this.notifyReposChanged() } }) } listProjects(): Project[] { return this.store?.getProjects?.() ?? [] } updateProject(projectId: string, updates: ProjectUpdateArgs['updates']): Project { if (!this.store?.updateProject) { throw new Error('runtime_unavailable') } const project = this.store.updateProject(projectId, updates) if (!project) { throw new Error(`Project not found: ${projectId}`) } this.invalidateResolvedWorktreeCache() this.notifyReposChanged() return project } listProjectHostSetups(): ProjectHostSetup[] { return this.store?.getProjectHostSetups?.() ?? [] } createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult { if (!this.store?.createProjectHostSetup) { throw new Error('runtime_unavailable') } const result = this.store.createProjectHostSetup(args) if (!result) { throw new Error(`Project not found: ${args.projectId}`) } return result } async setupProjectExistingFolder( args: ProjectHostSetupExistingFolderArgs ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } assertProjectHostSetupHostIsSupported(args.hostId) const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id)) const repo = await this.addRepo( args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId ) return this.completeProjectHostSetup(args, repo, !knownRepoIds.has(repo.id)) } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { // Why: guard before cloneRepo, which would otherwise clone to the local disk. assertProjectHostSetupHostIsSupported(args.hostId) const knownRepoIds = new Set(this.listRepos().map((repo) => repo.id)) const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return this.completeProjectHostSetup( { ...args, path: repo.path, kind: 'git', setupMethod: 'cloned' }, repo, !knownRepoIds.has(repo.id) ) } private completeProjectHostSetup( args: ProjectHostSetupExistingFolderArgs, initialRepo: Repo, repoWasCreated: boolean ): ProjectHostSetupResult { try { return this.linkRepoToProjectHostSetup(args, initialRepo) } catch (err) { if (repoWasCreated) { // Why: a failed link must not leave a new repo registration or stale host caches behind. this.store?.removeProject?.(initialRepo.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(initialRepo.id) invalidateAuthorizedRootsCache() this.notifyReposChanged() } throw err } } private linkRepoToProjectHostSetup( args: ProjectHostSetupExistingFolderArgs, initialRepo: Repo ): ProjectHostSetupResult { if (!this.store) { throw new Error('runtime_unavailable') } let repo = initialRepo let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { const existingProject = this.listProjects().find((project) => project.id === args.projectId) // Why: the selected project can exist only on the source host, so its structured identity travels with the request. const identity = existingProject?.providerIdentity ?? args.projectProviderIdentity if (!identity || getProjectIdForProviderIdentity(identity) !== args.projectId) { throw new Error('Imported folder does not match the selected project identity.') } const updated = this.store.updateRepo(repo.id, { upstream: { owner: identity.owner, repo: identity.repo, ...(identity.host ? { host: identity.host } : {}) } }) if (!updated) { throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`) } repo = updated setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) } const setupMethod = args.setupMethod ?? 'imported-existing-folder' const updated = this.store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod }) if (!updated) { throw new Error( `Project setup repo disappeared before setup metadata could be linked: ${repo.id}` ) } repo = updated setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) const project = this.listProjects().find((entry) => entry.id === setup.projectId) if (!project) { throw new Error(`Project setup was created without a project record: ${setup.projectId}`) } return { project, setup, repo } } updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult { if (!this.store?.updateProjectHostSetup) { throw new Error('runtime_unavailable') } const result = this.store.updateProjectHostSetup(args) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) } if ('worktreeBasePath' in args.updates && result.repo) { void prepareLocalWorktreeRootForRepo(this.store, result.repo) invalidateAuthorizedRootsCache() } return result } deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult { if (!this.store?.deleteProjectHostSetup) { throw new Error('runtime_unavailable') } const result = this.store.deleteProjectHostSetup(args) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) } return result } listProjectGroups(): ProjectGroup[] { return this.store?.getProjectGroups?.() ?? [] } listFolderWorkspaces(): FolderWorkspace[] { return this.store?.getFolderWorkspaces?.() ?? [] } async createProjectGroup(input: { name: string parentPath?: string | null connectionId?: string | null parentGroupId?: string | null createdFrom?: ProjectGroup['createdFrom'] }): Promise { if (!this.store?.createProjectGroup) { throw new Error('runtime_unavailable') } const group = this.store.createProjectGroup({ name: input.name, parentPath: input.parentPath ?? null, connectionId: input.connectionId ?? null, parentGroupId: input.parentGroupId ?? null, createdFrom: input.createdFrom ?? 'manual' }) this.notifyReposChanged() return group } async updateProjectGroup( groupId: string, updates: Partial> ): Promise { if (!this.store?.updateProjectGroup) { throw new Error('runtime_unavailable') } const updated = this.store.updateProjectGroup(groupId, updates) if (updated) { this.notifyReposChanged() } return updated } async deleteProjectGroup(groupId: string): Promise<{ deleted: boolean }> { if (!this.store?.deleteProjectGroup) { throw new Error('runtime_unavailable') } const deleted = this.store.deleteProjectGroup(groupId) if (deleted) { this.notifyReposChanged() } return { deleted } } async moveProjectToGroup( repoSelector: string, groupId: string | null, order?: number ): Promise { if (!this.store?.moveProjectToGroup) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) const moved = this.store.moveProjectToGroup(repo.id, groupId, order) if (!moved) { throw new Error('repo_not_found') } this.notifyReposChanged() return moved } async createFolderWorkspace(input: { projectGroupId: string name?: string folderPath?: string | null connectionId?: string | null linkedTask?: FolderWorkspace['linkedTask'] linkedTaskSourceContext?: FolderWorkspace['linkedTaskSourceContext'] createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }): Promise { if (!this.store?.createFolderWorkspace) { throw new Error('runtime_unavailable') } const projectGroups = this.store.getProjectGroups?.() ?? [] const group = projectGroups.find((entry) => entry.id === input.projectGroupId) const folderPath = typeof input.folderPath === 'string' && input.folderPath.trim().length > 0 ? input.folderPath : group?.parentPath if (!group || !folderPath) { throw new Error('folder_workspace_project_group_not_found') } const status = await getFolderWorkspacePathStatusForPath( { folderPath, projectGroupId: group.id, connectionId: input.connectionId ?? group.connectionId ?? null, projectGroups, repos: this.store.getRepos() }, { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) const workspace = this.store.createFolderWorkspace(input) this.notifyReposChanged() return workspace } async getFolderWorkspacePathStatus( request: FolderWorkspacePathStatusRequest ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } return getFolderWorkspacePathStatus(this.store, request, { getSshFilesystemProvider }) } async updateFolderWorkspace( folderWorkspaceId: string, updates: Partial< Pick< FolderWorkspace, | 'name' | 'folderPath' | 'linkedTask' | 'linkedTaskSourceContext' | 'comment' | 'isArchived' | 'isUnread' | 'isPinned' | 'sortOrder' | 'manualOrder' | 'workspaceStatus' | 'createdWithAgent' | 'pendingFirstAgentMessageRename' | 'firstAgentMessageRenameError' | 'lastActivityAt' > > ): Promise { if (!this.store?.updateFolderWorkspace) { throw new Error('runtime_unavailable') } if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) { const workspace = this.store .getFolderWorkspaces?.() .find((entry) => entry.id === folderWorkspaceId) if (!workspace) { return null } const projectGroups = this.store.getProjectGroups?.() ?? [] const status = await getFolderWorkspacePathStatusForPath( { folderPath: updates.folderPath, projectGroupId: workspace.projectGroupId, connectionId: workspace.connectionId ?? projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ?? null, projectGroups, repos: this.store.getRepos() }, { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) } const updated = this.store.updateFolderWorkspace(folderWorkspaceId, updates) if (updated) { this.notifyReposChanged() } return updated } async deleteFolderWorkspace(folderWorkspaceId: string): Promise<{ deleted: boolean }> { if (!this.store?.removeFolderWorkspace) { throw new Error('runtime_unavailable') } const deleted = this.store.removeFolderWorkspace(folderWorkspaceId) if (deleted) { this.notifyReposChanged() } return { deleted } } async scanNestedRepos(path: string): Promise { if (!isAbsolute(path)) { throw new Error('Project path must be an absolute path') } return scanNestedRepos({ path, options: { timeoutMs: 15_000 } }) } async browseServerDir(pathValue: string): Promise<{ resolvedPath: string entries: DirEntry[] pathFlavor: FilesystemPathFlavor }> { // Windows resolves `/` to the current drive, so expose drive roots instead. if (isServerDriveListRequest(pathValue)) { return listWindowsDrives() } const dirPath = resolveServerBrowsePath(pathValue) const dirStat = await stat(dirPath) if (!dirStat.isDirectory()) { throw new Error(`${dirPath} is not a directory`) } const entries = await readdir(dirPath, { withFileTypes: true }) const mapped = entries .filter((entry) => entry.name !== '.' && entry.name !== '..') .map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink() })) mapped.sort((a, b) => { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } return a.name.localeCompare(b.name) }) return { resolvedPath: dirPath, entries: mapped, pathFlavor: process.platform === 'win32' ? 'win32' : 'posix' } } async isGitAvailable(): Promise { try { await gitExecFileAsync(['--version'], { cwd: process.cwd(), timeout: 3000 }) return true } catch { return false } } async importNestedRepos(args: { parentPath: string groupName: string projectPaths: string[] mode: ProjectGroupImportMode }): Promise { if (!this.store?.createProjectGroup || !this.store?.moveProjectToGroup) { throw new Error('runtime_unavailable') } if (!isAbsolute(args.parentPath)) { throw new Error('Project path must be an absolute path') } const scan = await scanNestedRepos({ path: args.parentPath, options: { timeoutMs: 15_000 } }) const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths }) const groupResolver = createNestedProjectGroupResolver({ parentPath: args.parentPath, groupName: args.groupName, mode: args.mode, connectionId: null, repoPaths: selection.selectedPaths, createGroup: (input) => this.store!.createProjectGroup!(input) }) const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map( (repoPath) => ({ path: repoPath, status: 'failed', error: 'Repository was not found in the nested repo scan result' }) ) const importedProjectIdsByRepoPath = new Map() const importTargetResolver = createNestedRepoImportTargetResolver() for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue } const importRepoPath = await importTargetResolver.resolveLocal(repoPath) const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) const alreadyImportedProjectId = importedProjectIdsByRepoPath.get(normalizedImportRepoPath) if (alreadyImportedProjectId) { results.push({ path: repoPath, projectId: alreadyImportedProjectId, status: 'already-known' }) continue } const existing = this.store .getRepos() .find((repo) => normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath) const group = groupResolver.getGroupForRepo(repoPath) if (existing) { if (group) { this.store.moveProjectToGroup(existing.id, group.id, projectGroupOrder) } importedProjectIdsByRepoPath.set(normalizedImportRepoPath, existing.id) results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) continue } const repo: Repo = { id: randomUUID(), path: importRepoPath, displayName: getRepoName(importRepoPath), badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false, ...(group ? { projectGroupId: group.id, projectGroupOrder } : {}) } this.store.addRepo(repo) importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) } catch (error) { results.push({ path: repoPath, status: 'failed', error: sanitizeNestedRepoRuntimeImportError( 'Failed to import nested repository in runtime', error ) }) } } const importedCount = results.filter((entry) => entry.status === 'imported').length const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length const failedCount = results.filter((entry) => entry.status === 'failed').length if (importedCount + alreadyKnownCount === 0) { for (const group of groupResolver.getCreatedGroups().toReversed()) { this.store.deleteProjectGroup?.(group.id) } } this.invalidateResolvedWorktreeCache() for (const project of results) { if (project.projectId) { this.invalidateWorktreeScanCacheForRepo(project.projectId) } } this.notifyReposChanged() const rootGroup = groupResolver.getRootGroup() return { ...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}), projects: results, importedCount, alreadyKnownCount, failedCount } } async listSparsePresets(repoSelector: string) { if (!this.store?.getSparsePresets) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) return this.store.getSparsePresets(repo.id) } async saveSparsePreset( repoSelector: string, args: { id?: string; name: string; directories: string[] } ) { if (!this.store?.getSparsePresets || !this.store.saveSparsePreset) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) const name = normalizeSparsePresetName(args.name) const directories = normalizeSparsePresetDirectoriesForSave(args.directories) const now = Date.now() const existing = args.id ? this.store.getSparsePresets(repo.id).find((preset) => preset.id === args.id) : undefined return this.store.saveSparsePreset({ id: existing?.id ?? randomUUID(), repoId: repo.id, name, directories, createdAt: existing?.createdAt ?? now, updatedAt: now }) } async addRepo( path: string, kind: 'git' | 'folder' = 'git', executionHostId?: ExecutionHostId | null ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } if (!isAbsolute(path)) { // Why: remote clients may run in a different cwd than the server. Require // server-side repo paths to be explicit so `orca serve` cwd is irrelevant. throw new Error('Project path must be an absolute path') } if (kind === 'git' && !isGitRepo(path)) { throw new Error(`Not a valid git repository: ${path}`) } const existing = this.store.getRepos().find((repo) => { if (!runtimePathsEqual(repo.path, path)) { return false } return runtimeRepoMatchesExecutionHost(repo, executionHostId) }) if (existing) { // Only a runtime host backfills a legacy unstamped repo. An unstamped repo is // indistinguishable from a genuine local repo (both have null executionHostId and // connectionId), so we never stamp local/ssh onto it — that would re-attribute a // real local project to the wrong host. Runtime is the only host that lost its // identity to the pre-#7018 path-only import and needs the backfill. if ( existing.executionHostId == null && parseExecutionHostId(executionHostId)?.kind === 'runtime' ) { const adopted = this.store.updateRepo(existing.id, { executionHostId }) ?? ({ ...existing, executionHostId } as Repo) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(existing.id) this.notifyReposChanged() return adopted } return existing } const detected = await detectRepoIconAndUpstream({ repoPath: path, kind }) const repo: Repo = { id: randomUUID(), path, displayName: getRepoName(path), badgeColor: DEFAULT_REPO_BADGE_COLOR, ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind, ...(kind === 'git' ? { externalWorktreeVisibility: 'hide' as const, externalWorktreeVisibilityLegacy: false } : {}) } this.store.addRepo(repo) await prepareLocalWorktreeRootForRepo(this.store, repo) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) this.notifyReposChanged() return this.store.getRepo(repo.id) ?? repo } async createRepo( parentPath: string, name: string, kind: 'git' | 'folder' = 'git' ): Promise<{ repo: Repo } | { error: string }> { if (!this.store) { throw new Error('runtime_unavailable') } const trimmedName = name.trim() const trimmedParentPath = parentPath.trim() const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' if (!trimmedName) { return { error: 'Name cannot be empty' } } if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { return { error: 'Name cannot contain slashes or be "." / ".."' } } if (!trimmedParentPath) { return { error: 'Parent directory is required' } } if (!isAbsolute(trimmedParentPath)) { return { error: 'Parent directory must be an absolute path' } } const targetPath = join(trimmedParentPath, trimmedName) const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath)) if (existing) { return { repo: existing } } let createdDir = false try { // Why: default create-project parents are host-home based and may not exist // before the first project is created on a fresh runtime. await mkdir(trimmedParentPath, { recursive: true }) const existingStat = await stat(targetPath).catch((error: unknown) => { if (isENOENT(error)) { return null } throw error }) if (existingStat) { if (!existingStat.isDirectory()) { return { error: `"${trimmedName}" already exists at this location and is not a folder.` } } const entries = await readdir(targetPath) if (entries.length > 0) { return { error: `"${trimmedName}" already exists at this location and is not empty.` } } } else { await mkdir(targetPath, { recursive: false }) createdDir = true } } catch (error) { const message = error instanceof Error ? error.message : String(error) return { error: `Failed to prepare directory: ${message}` } } if (repoKind === 'git') { let step: 'init' | 'commit' = 'init' try { await gitExecFileAsync(['init'], { cwd: targetPath }) step = 'commit' await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], { cwd: targetPath }) } catch (error) { if (createdDir) { await rm(targetPath, { recursive: true, force: true }).catch(() => {}) } else if (step === 'commit') { await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {}) } const message = error instanceof Error ? error.message : String(error) if ( step === 'commit' && /Please tell me who you are|user\.name|user\.email/i.test(message) ) { return { error: 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.' } } const stepLabel = step === 'init' ? 'Failed to initialize git repository' : 'Failed to create initial commit' return { error: `${stepLabel}: ${message}` } } } const raceWinner = this.store .getRepos() .find((repo) => runtimePathsEqual(repo.path, targetPath)) if (raceWinner) { return { repo: raceWinner } } const detected = await detectRepoIconAndUpstream({ repoPath: targetPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), path: targetPath, displayName: trimmedName, badgeColor: DEFAULT_REPO_BADGE_COLOR, ...detected, addedAt: Date.now(), kind: repoKind, ...(repoKind === 'git' ? { externalWorktreeVisibility: 'hide' as const, externalWorktreeVisibilityLegacy: false } : {}) } this.store.addRepo(repo) await prepareLocalWorktreeRootForRepo(this.store, repo) invalidateAuthorizedRootsCache() this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) this.notifyReposChanged() return { repo: this.store.getRepo(repo.id) ?? repo } } async cloneRepo( url: string, destination: string, executionHostId?: ExecutionHostId | null ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const trimmedUrl = url.trim() const trimmedDestination = destination.trim() if (!trimmedDestination) { throw new Error('Clone destination is required') } const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination }) const clonePathKey = getClonePathComparisonKey(clonePath) const previous = this.cloneInFlightByPath.get(clonePathKey) ?? Promise.resolve() let release!: () => void const current = new Promise((resolve) => { release = resolve }) const tail = previous.then( () => current, () => current ) this.cloneInFlightByPath.set(clonePathKey, tail) try { await previous return await runWithGitReadCacheInvalidation(() => this.cloneRepoAfterPathLock( trimmedUrl, trimmedDestination, clonePath, clonePathKey, executionHostId ) ) } finally { release() if (this.cloneInFlightByPath.get(clonePathKey) === tail) { this.cloneInFlightByPath.delete(clonePathKey) } } } private async cloneRepoAfterPathLock( trimmedUrl: string, trimmedDestination: string, clonePath: string, clonePathKey: string, executionHostId?: ExecutionHostId | null ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const existingBeforeClone = this.store .getRepos() .find( (repo) => getClonePathComparisonKey(repo.path) === clonePathKey && runtimeRepoMatchesExecutionHost(repo, executionHostId) ) if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { return existingBeforeClone } await mkdir(trimmedDestination, { recursive: true }) const claimedTarget = await claimCloneTarget(clonePath) await new Promise((resolve, reject) => { let proc: ReturnType try { proc = gitSpawn(['clone', '--progress', '--', trimmedUrl, clonePath], { cwd: trimmedDestination, // Why: without the non-interactive guard, a clone that needs GitHub // auth makes Git Credential Manager pop its "Connect to GitHub" OAuth // window on Windows; in a network-restricted env the browser/device // flow can never complete and git's credential retry re-pops it // (issue #7652). Fail fast with a clear error instead. env: nonInteractiveGitEnv(), stdio: ['ignore', 'ignore', 'pipe'] }) } catch (err) { void cleanupClaimedCloneTarget(clonePath, claimedTarget).finally(() => { const message = err instanceof Error ? err.message : String(err) reject(new Error(`Clone failed: ${message}`)) }) return } let stderrTail = '' let settled = false proc.stderr?.on('data', (chunk: Buffer) => { stderrTail = (stderrTail + chunk.toString()).slice(-4096) }) const finishClone = async ( code: number | null, signal: NodeJS.Signals | null, error?: Error ) => { if (settled) { return } settled = true const cloneSucceeded = !error && code === 0 && !signal if (!cloneSucceeded) { await cleanupClaimedCloneTarget(clonePath, claimedTarget) } if (error) { reject(new Error(`Clone failed: ${error.message}`)) } else if (signal === 'SIGTERM') { reject(new Error('Clone aborted')) } else if (code === 0) { resolve() } else { reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`)) } } proc.on('error', (error) => { void finishClone(null, null, error) }) proc.on('close', (code, signal) => { void finishClone(code, signal) }) }) const existing = this.store .getRepos() .find( (repo) => getClonePathComparisonKey(repo.path) === clonePathKey && runtimeRepoMatchesExecutionHost(repo, executionHostId) ) if (existing) { if (isFolderRepo(existing)) { const updated = this.store.updateRepo(existing.id, { kind: 'git' }) if (updated) { await prepareLocalWorktreeRootForRepo(this.store, updated) invalidateAuthorizedRootsCache() this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(updated.id) this.notifyReposChanged() return updated } } return existing } const detected = await detectRepoIconAndUpstream({ repoPath: clonePath, kind: 'git' }) const repo: Repo = { id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind: 'git', externalWorktreeVisibility: 'hide', externalWorktreeVisibilityLegacy: false } this.store.addRepo(repo) await prepareLocalWorktreeRootForRepo(this.store, repo) invalidateAuthorizedRootsCache() this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) this.notifyReposChanged() return this.store.getRepo(repo.id) ?? repo } async showRepo(repoSelector: string): Promise { return await this.resolveRepoSelector(repoSelector) } async setRepoBaseRef(repoSelector: string, baseRef: string): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { throw new Error('Folder mode does not support base refs.') } const updated = this.store.updateRepo(repo.id, { worktreeBaseRef: baseRef }) if (!updated) { throw new Error('repo_not_found') } this.invalidateResolvedWorktreeCache() this.notifyReposChanged() return updated } async updateRepo( repoSelector: string, updates: Partial< Pick< Repo, | 'displayName' | 'badgeColor' | 'repoIcon' | 'upstream' | 'hookSettings' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' | 'symlinkPaths' | 'issueSourcePreference' | 'externalWorktreeVisibility' | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'projectGroupId' | 'projectGroupOrder' > > & { sourceControlAi?: Repo['sourceControlAi'] | null externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null } ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) const sanitizedUpdates = omitUndefinedProperties(updates) if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) { sanitizedUpdates.worktreeBasePath = undefined } if ( 'externalWorktreeDiscoverySuppressedAt' in updates && updates.externalWorktreeDiscoverySuppressedAt === null ) { sanitizedUpdates.externalWorktreeDiscoverySuppressedAt = undefined } if ('sourceControlAi' in updates && updates.sourceControlAi === null) { sanitizedUpdates.sourceControlAi = null } const updated = this.store.updateRepo(repo.id, sanitizedUpdates) if (!updated) { throw new Error('repo_not_found') } if ('worktreeBasePath' in updates) { await prepareLocalWorktreeRootForRepo(this.store, updated) invalidateAuthorizedRootsCache() } this.invalidateResolvedWorktreeCache() if ('worktreeBasePath' in updates) { this.invalidateWorktreeScanCacheForRepo(repo.id) } this.notifyReposChanged() return updated } async removeProject(repoSelector: string): Promise<{ removed: true }> { if (!this.store?.removeProject) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(repoSelector) // Why: removeProject is id-only, but the same id may be registered on a sibling // execution host; a path:/name: selector resolves one row and must remove only it. const hostId = getRepoExecutionHostId(repo) const idExistsOnOtherHost = this.store .getRepos() .some((entry) => entry.id === repo.id && getRepoExecutionHostId(entry) !== hostId) if (idExistsOnOtherHost) { if (!this.store.removeProjectForHost) { throw new Error('runtime_unavailable') } this.store.removeProjectForHost(repo.id, hostId) } else { this.store.removeProject(repo.id) } this.terminalTopologyRevisionByRepoId.delete(repo.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) invalidateAuthorizedRootsCache() this.notifyReposChanged() return { removed: true } } async inspectTerminalProcess( terminalSelector: string ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> { const leaf = this.resolveLiveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { throw new Error('terminal_gone') } if (this.ptyController.inspectProcess) { return this.ptyController.inspectProcess(leaf.ptyId) } const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false return { foregroundProcess, hasChildProcesses } } reorderRepos(orderedIds: string[]): { status: 'applied' | 'rejected' } { if (!this.store?.reorderRepos) { throw new Error('runtime_unavailable') } // Why: remote clients can race repo add/remove on the server just like // local drag-reorder can race another window. Let the store validate the // full permutation and signal a resync-worthy rejection. const applied = this.store.reorderRepos(orderedIds) if (!applied) { return { status: 'rejected' } } this.invalidateResolvedWorktreeCache() this.notifyReposChanged() return { status: 'applied' } } async searchRepoRefs( repoSelector: string, query: string, limit = DEFAULT_REPO_SEARCH_REFS_LIMIT ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { return { refs: [], truncated: false } } const refDetails = repo.connectionId ? await this.searchRemoteRepoRefs(repo, query, limit + 1) : await searchBaseRefDetails(repo.path, query, limit + 1) return { refs: refDetails.slice(0, limit).map((entry) => entry.refName), refDetails: refDetails.slice(0, limit), truncated: refDetails.length > limit } } async getRepoBaseRefDefault( repoSelector: string ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { return { defaultBaseRef: null, remoteCount: 0 } } if (repo.connectionId) { return this.getRemoteRepoBaseRefDefault(repo) } const [defaultBaseRef, remoteCount] = await Promise.all([ getBaseRefDefault(repo.path), getRemoteCount(repo.path) ]) return { defaultBaseRef, remoteCount } } private async getRemoteRepoBaseRefDefault( repo: Repo ): Promise<{ defaultBaseRef: string | null; remoteCount: number }> { const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null if (!provider) { return { defaultBaseRef: null, remoteCount: 0 } } const [defaultBaseRef, remoteCount] = await Promise.all([ resolveDefaultBaseRefViaExec(async (argv) => { try { return await provider.exec(argv, repo.path) } catch (err) { if (argv[0] === 'symbolic-ref') { console.warn('[runtime:repo.baseRefDefault] SSH symbolic-ref failed', { path: repo.path, err }) } throw err } }), provider .exec(['remote'], repo.path) .then((result) => parseRemoteCount(result.stdout)) .catch((err) => { console.warn('[runtime:repo.baseRefDefault] SSH git remote count failed', { path: repo.path, err }) return 0 }) ]) return { defaultBaseRef, remoteCount } } private async searchRemoteRepoRefs( repo: Repo, query: string, limit: number ): Promise { const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null if (!provider) { return [] } const normalizedQuery = normalizeRefSearchQuery(query) try { const remotesResult = await provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' })) const remotes = remotesResult.stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) const capabilities = getSshGitCapabilityCache(provider) const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { return capabilities.runWithFallback( 'for-each-ref-exclude', async () => ( await provider.exec( buildSearchBaseRefsArgv(normalizedQuery, limit, { remoteNames: remotes, patternGroup }), repo.path ) ).stdout, async () => ( await provider.exec( buildSearchBaseRefsArgv(normalizedQuery, limit, { excludeRemoteHead: false, remoteNames: remotes, patternGroup }), repo.path ) ).stdout, isForEachRefExcludeUnsupportedError ) } const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) if (searchTokens.length > 1) { const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')]) return mergeBaseRefSearchResultGroups( results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)), limit ) } return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes) } catch (err) { console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', { path: repo.path, err }) return [] } } private async resolveHostedReviewTarget(args: { repoSelector: string worktreeSelector?: string }): Promise<{ repo: Repo; repoPath: string }> { const repo = await this.resolveRepoSelector(args.repoSelector) if (!args.worktreeSelector) { return { repo, repoPath: repo.path } } const worktree = await this.resolveWorktreeSelector(args.worktreeSelector) if (worktree.repoId !== repo.id) { throw new Error('Access denied: worktree does not belong to repository') } return { repo, repoPath: worktree.path } } private getHostedReviewExecutionOptions( repo: Repo ): { localGitExecOptions: { wslDistro?: string } } | undefined { const localGitOptions = this.getLocalGitExecutionOptionArgs(repo)[0] ?? {} return Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : undefined } private getLocalGitExecutionOptionArgs(repo: Repo): [] | [{ wslDistro?: string }] { const localGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] } private getAgentLaunchPlatformForRepo(repo: Repo): NodeJS.Platform { const projectRuntime = repo.connectionId ? undefined : resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) return getAgentLaunchPlatformForRepo(repo, projectRuntime) } private getAgentLaunchPlatformForWorkspace(scope: TerminalWorkspaceLaunchScope): NodeJS.Platform { if (scope.repo) { return this.getAgentLaunchPlatformForRepo(scope.repo) } if (scope.connectionId) { return isWindowsAbsolutePathLike(scope.path) ? 'win32' : 'linux' } return isWslUncPath(scope.path) ? 'linux' : process.platform } async getRepoSlug(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options ? getRepoSlug(repo.path, repo.connectionId ?? null, options) : getRepoSlug(repo.path, repo.connectionId ?? null) } async getRepoUpstream(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options = this.getHostedReviewExecutionOptions(repo) return options ? getRepoUpstream(repo.path, repo.connectionId ?? null, options) : getRepoUpstream(repo.path, repo.connectionId ?? null) } // Why: repos added before fork detection existed have no stored `upstream`, so // their avatar/badge would never self-correct. Resolve it once at startup for // local git repos; SSH repos resolve lazily when their settings open (their // connection may not be up yet). Sequential to respect the gh rate limit; // failures leave `upstream` unset so the next launch retries. private async backfillForkUpstreams(): Promise { try { const store = this.requireStore() let changed = false for (const repo of store.getRepos()) { if (repo.upstream !== undefined || repo.kind === 'folder' || repo.connectionId) { continue } let upstream: GitHubOwnerRepo | null try { upstream = await getRepoUpstream(repo.path, null) } catch { continue } const updates: Partial = { upstream: upstream ?? null } // Only migrate the auto-detected origin avatar; never touch a chosen icon. if (upstream && repo.repoIcon?.type === 'image' && repo.repoIcon.source === 'github') { updates.repoIcon = githubAvatarIcon(upstream) } store.updateRepo(repo.id, updates) changed = true } if (changed) { this.notifyReposChanged() } } catch { // Best-effort startup backfill; never disrupt launch. } } async listRepoWorkItems( repoSelector: string, limit?: number, query?: string, page?: number, noCache?: boolean ): Promise> { const repo = await this.resolveRepoSelector(repoSelector) return listWorkItems( repo.path, limit, query, page, repo.issueSourcePreference, repo.connectionId ?? null, noCache, ...this.getLocalGitExecutionOptionArgs(repo) ) } async listRepoIssues( repoSelector: string, limit?: number ): Promise>['items']> { const repo = await this.resolveRepoSelector(repoSelector) const result = await listGitHubIssues( repo.path, limit, repo.issueSourcePreference, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) return result.items } async getRepoWorkItem( repoSelector: string, number: number, type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) // Why: open-by-number must pin the same source the list and start-point use, // else a fork and its upstream sharing a PR number resolve to different PRs. return getWorkItem( repo.path, number, type, repo.connectionId ?? null, this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, repo.issueSourcePreference ) } async getRepoWorkItemByOwnerRepo( repoSelector: string, ownerRepo: { owner: string; repo: string; host?: string }, number: number, type: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getWorkItemByOwnerRepo( repo.path, ownerRepo, number, type, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getRepoWorkItemDetails( repoSelector: string, number: number, type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getWorkItemDetails( repo.path, number, type, repo.connectionId ?? null, this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, repo.issueSourcePreference ) } async countRepoWorkItems(repoSelector: string, query?: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) return countWorkItems( repo.path, query, repo.issueSourcePreference, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async listRepoLabels(repoSelector: string): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return listLabels( repo.path, repo.issueSourcePreference, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async listRepoAssignableUsers( repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return listAssignableUsers( repo.path, repo.issueSourcePreference, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } getGitHubRateLimit(options?: { force?: boolean }): Promise>> { return getRateLimit(options) } async getRepoPRForBranch( repoSelector: string, branch: string, linkedPRNumber?: number | null, fallbackPRNumber?: number | null, acceptMergedFallbackPR?: boolean, currentHeadOid?: string | null ): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {} const lookupOptions = { ...options } if (acceptMergedFallbackPR === true) { lookupOptions.acceptMergedFallbackPR = true } if (typeof currentHeadOid === 'string' && currentHeadOid.trim().length > 0) { lookupOptions.currentHeadOid = currentHeadOid.trim() } const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] // Why: return the full classified outcome (not PRInfo|null) so a runtime gh // auth/network failure crosses the RPC as `upstream-error` instead of // collapsing to `null`, which the renderer would otherwise cache as a false // accepted "no PR found" (design success criterion 1). return getPRForBranchOutcome( repo.path, branch, linkedPRNumber ?? null, repo.connectionId ?? null, linkedPRNumber == null ? (fallbackPRNumber ?? null) : null, ...lookupOptionArgs ) } async getHostedReviewForBranch(args: { repoSelector: string branch: string currentHeadOid?: string | null active?: boolean linkedGitHubPR?: number | null fallbackGitHubPR?: number | null linkedGitLabMR?: number | null linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null }): Promise { const repo = await this.resolveRepoSelector(args.repoSelector) const executionOptions = this.getHostedReviewExecutionOptions(repo) const review = await getHostedReviewForBranchFromRepo({ repoPath: repo.path, connectionId: repo.connectionId ?? null, branch: args.branch, currentHeadOid: args.currentHeadOid ?? null, ...(args.active === true ? { active: true } : {}), linkedGitHubPR: args.linkedGitHubPR ?? null, fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, ...executionOptions }) if (review?.provider === 'github' && this.stats && !this.stats.hasCountedPR(review.url)) { this.stats.record({ type: 'pr_created', at: Date.now(), repoId: repo.id, meta: { prNumber: review.number, prUrl: review.url } }) } return review } async getHostedReviewCreationEligibility( args: Omit & { repoSelector: string worktreeSelector?: string } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) const executionOptions = this.getHostedReviewExecutionOptions(repo) return getHostedReviewCreationEligibilityFromRepo({ repoPath, connectionId: repo.connectionId ?? null, branch: args.branch, base: args.base ?? null, hasUncommittedChanges: args.hasUncommittedChanges, hasUpstream: args.hasUpstream, ahead: args.ahead, behind: args.behind, linkedGitHubPR: args.linkedGitHubPR ?? null, fallbackGitHubPR: args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null, linkedGitLabMR: args.linkedGitLabMR ?? null, linkedBitbucketPR: args.linkedBitbucketPR ?? null, linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, linkedGiteaPR: args.linkedGiteaPR ?? null, ...executionOptions }) } async createHostedReview( args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string } ): Promise { const { repo, repoPath } = await this.resolveHostedReviewTarget(args) const executionOptions = this.getHostedReviewExecutionOptions(repo) const input = { provider: args.provider, base: args.base, head: args.head, title: args.title, body: args.body, draft: args.draft, ...(args.useTemplate !== undefined ? { useTemplate: args.useTemplate } : {}) } const result = executionOptions ? await createHostedReviewFromRepo( repoPath, input, repo.connectionId ?? null, executionOptions ) : await createHostedReviewFromRepo(repoPath, input, repo.connectionId ?? null) if (result.ok && this.stats && !this.stats.hasCountedPR(result.url)) { this.stats.record({ type: 'pr_created', at: Date.now(), repoId: repo.id, meta: { prNumber: result.number, prUrl: result.url } }) } return result } async listGitLabRepoWorkItems( repoSelector: string, state?: MRListState, page?: number, perPage?: number, query?: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return listGitLabWorkItems( repo.path, state ?? 'opened', page ?? 1, perPage ?? 20, repo.issueSourcePreference, query, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async listGitLabRepoMRs( repoSelector: string, state?: MRListState, page?: number, perPage?: number, query?: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return listGitLabMergeRequests( repo.path, normalizeGitLabMRListState(state), normalizeGitLabPositiveInteger(page, 1, 10_000), normalizeGitLabPositiveInteger(perPage, 20, 100), repo.issueSourcePreference, query, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async listGitLabRepoIssues( repoSelector: string, state?: GitLabIssueListState, assignee?: string, limit?: number ): Promise<{ items: GitLabWorkItem[] error?: Awaited>['error'] }> { const repo = await this.resolveRepoSelector(repoSelector) const normalized = normalizeGitLabIssueListArgs({ state, assignee, limit }) const result = await listGitLabIssues( repo.path, normalized.limit, repo.issueSourcePreference, normalized.state, normalized.assignee, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) // Why: web runtime mirrors the desktop preload contract, where GitLab // issue rows share the GitLabWorkItem shape with MRs on TaskPage. const items: GitLabWorkItem[] = result.items.map((issue) => ({ id: `gitlab-issue-${repo.id}-${issue.number}`, type: 'issue' as const, number: issue.number, title: issue.title, state: issue.state, url: issue.url, labels: issue.labels, updatedAt: issue.updatedAt ?? '', author: issue.author ?? null, repoId: repo.id })) return { items, ...(result.error ? { error: result.error } : {}) } } async listGitLabRepoTodos( repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return listGitLabTodos( repo.path, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async diagnoseGitLabAuth(): Promise>> { return diagnoseGitLabAuthClient() } async getGitLabRateLimit(options?: { force?: boolean host?: string | null }): Promise>> { return getGitLabRateLimit(options) } async listGitLabRepoLabels( repoSelector: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return listGitLabLabels( repo.path, repo.issueSourcePreference, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async createGitLabRepoIssue( repoSelector: string, title: string, body: string ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return createGitLabIssue( repo.path, title, body, repo.issueSourcePreference, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateGitLabRepoIssue( repoSelector: string, number: number, updates: GitLabIssueUpdate, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updateGitLabIssue( repo.path, number, updates, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async addGitLabRepoIssueComment( repoSelector: string, number: number, body: string, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return addGitLabIssueComment( repo.path, number, body, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async addGitLabRepoMRComment( repoSelector: string, iid: number, body: string, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return addGitLabMRComment( repo.path, iid, body, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async addGitLabRepoMRInlineComment( repoSelector: string, iid: number, input: GitLabMRInlineCommentInput, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return addGitLabMRInlineComment( repo.path, iid, input, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async resolveGitLabRepoMRDiscussion( repoSelector: string, iid: number, discussionId: string, resolved: boolean, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return resolveGitLabMRDiscussion( repo.path, iid, discussionId, resolved, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getGitLabRepoJobTrace( repoSelector: string, jobId: number, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getGitLabJobTrace( repo.path, jobId, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async retryGitLabRepoJob( repoSelector: string, jobId: number, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return retryGitLabJob( repo.path, jobId, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async mergeGitLabRepoMR( repoSelector: string, iid: number, method?: 'merge' | 'squash' | 'rebase', projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return mergeGitLabMR( repo.path, iid, method ?? 'merge', repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateGitLabRepoMRState( repoSelector: string, iid: number, state: 'opened' | 'closed', projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return state === 'closed' ? closeGitLabMR( repo.path, iid, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) : reopenGitLabMR( repo.path, iid, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateGitLabRepoMR( repoSelector: string, iid: number, updates: { title?: string; body?: string; addLabels?: string[]; removeLabels?: string[] }, projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updateGitLabMR( repo.path, iid, updates, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateGitLabRepoMRReviewers( repoSelector: string, iid: number, reviewerIds: number[], projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updateGitLabMRReviewers( repo.path, iid, reviewerIds, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getGitLabRepoWorkItemDetails( repoSelector: string, iid: number, type: 'issue' | 'mr', projectRef?: GitLabProjectRef | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getGitLabWorkItemDetails( repo.path, iid, type, repo.issueSourcePreference, repo.connectionId ?? null, projectRef, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getGitLabRepoWorkItemByPath( repoSelector: string, projectRef: GitLabProjectRef, iid: number, type: 'issue' | 'mr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) const result = await getGitLabWorkItemByProjectRef( repo.path, projectRef, iid, type, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) // Why: remote pasted-URL lookups should update GitLab recents exactly // like the desktop IPC path, but only after a successful lookup. if (result && this.store?.updateSettings) { const store = this.store recordGitLabProjectRecent( { getSettings: () => store.getSettings(), updateSettings: (updates) => store.updateSettings?.(updates) }, projectRef.host, projectRef.path ) } return result } async getRepoIssue( repoSelector: string, number: number ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getIssue( repo.path, number, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getRepoPRChecks( repoSelector: string, prNumber: number, headSha?: string, prRepo?: GitHubOwnerRepo | null, options?: { noCache?: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getPRChecks( repo.path, prNumber, headSha, prRepo ?? null, options, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async rerunRepoPRChecks( repoSelector: string, prNumber: number, options?: { headSha?: string failedOnly?: boolean prRepo?: GitHubOwnerRepo | null } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return rerunPRChecks( repo.path, prNumber, options, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getRepoPRCheckDetails( repoSelector: string, args: { checkRunId?: number workflowRunId?: number checkName?: string url?: string | null prRepo?: GitHubOwnerRepo | null } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getPRCheckDetails( repo.path, { ...args, prRepo: args.prRepo ?? null }, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getRepoPRComments( repoSelector: string, prNumber: number, prRepo?: GitHubOwnerRepo | null, options?: { noCache?: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getPRComments( repo.path, prNumber, { ...options, prRepo: prRepo ?? null }, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async getRepoPRFileContents( repoSelector: string, args: { prNumber: number prRepo?: GitHubOwnerRepo | null path: string oldPath?: string status: GitHubPRFile['status'] headSha: string baseSha: string } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return getPRFileContents({ repoPath: repo.path, connectionId: repo.connectionId ?? null, localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], ...args }) } async resolveRepoReviewThread( repoSelector: string, threadId: string, resolve: boolean, prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return resolveReviewThread( repo.path, threadId, resolve, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async setRepoPRFileViewed( repoSelector: string, args: { prRepo?: GitHubOwnerRepo | null pullRequestId: string path: string viewed: boolean } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return setPRFileViewed({ repoPath: repo.path, connectionId: repo.connectionId ?? null, localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], ...args }) } async updateRepoPRTitle( repoSelector: string, prNumber: number, title: string, prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updatePRTitle( repo.path, prNumber, title, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateRepoPRDetails( repoSelector: string, prNumber: number, updates: { title?: string; body?: string }, prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updatePRDetails( repo.path, prNumber, updates, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async mergeRepoPR( repoSelector: string, prNumber: number, method?: 'merge' | 'squash' | 'rebase', prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return mergePR( repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async setRepoPRAutoMerge( repoSelector: string, prNumber: number, enabled: boolean, method?: 'merge' | 'squash' | 'rebase', prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return setPRAutoMerge( repo.path, prNumber, enabled, method, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateRepoPRState( repoSelector: string, prNumber: number, updates: GitHubPullRequestStateUpdate, prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updatePRState( repo.path, prNumber, updates, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async requestRepoPRReviewers( repoSelector: string, prNumber: number, reviewers: string[], prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return requestPRReviewers( repo.path, prNumber, reviewers, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async removeRepoPRReviewers( repoSelector: string, prNumber: number, reviewers: string[], prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return removePRReviewers( repo.path, prNumber, reviewers, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async createRepoIssue( repoSelector: string, title: string, body: string, fields?: GitHubCreateIssueFields ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return createIssue( repo.path, title, body, repo.issueSourcePreference, repo.connectionId ?? null, fields, ...this.getLocalGitExecutionOptionArgs(repo) ) } async updateRepoIssue( repoSelector: string, number: number, updates: GitHubIssueUpdate ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return updateIssue( repo.path, number, updates, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async addRepoIssueComment( repoSelector: string, number: number, body: string, prRepo?: GitHubOwnerRepo | null ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return addIssueComment( repo.path, number, body, repo.connectionId ?? null, prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async addRepoPRReviewComment( repoSelector: string, args: Omit ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return addPRReviewComment({ repoPath: repo.path, connectionId: repo.connectionId ?? null, localGitOptions: this.getLocalGitExecutionOptionArgs(repo)[0], ...args }) } async addRepoPRReviewCommentReply( repoSelector: string, args: { prNumber: number commentId: number body: string threadId?: string path?: string line?: number prRepo?: GitHubOwnerRepo | null } ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) return addPRReviewCommentReply( repo.path, args.prNumber, args.commentId, args.body, args.threadId, args.path, args.line, repo.connectionId ?? null, args.prRepo ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } async listGitHubProjects( args?: ListAccessibleProjectsArgs ): Promise>> { return listAccessibleProjects(args) } async listGitHubLabelsBySlug( args: ListLabelsBySlugArgs ): Promise>> { return listLabelsBySlug(args) } async listGitHubAssignableUsersBySlug( args: ListAssignableUsersBySlugArgs ): Promise>> { return listAssignableUsersBySlug(args) } async listGitHubIssueTypesBySlug( args: ListIssueTypesBySlugArgs ): Promise>> { return listIssueTypesBySlug(args) } async resolveGitHubProjectRef( args: ResolveProjectRefArgs ): Promise>> { return resolveProjectRef(args) } async listGitHubProjectViews( args: ListProjectViewsArgs ): Promise>> { return listProjectViews(args) } async getGitHubProjectViewTable( args: GetProjectViewTableArgs ): Promise>> { return getProjectViewTable(args) } async getGitHubProjectWorkItemDetailsBySlug( args: ProjectWorkItemDetailsBySlugArgs ): Promise>> { return getWorkItemDetailsBySlug(args) } async updateGitHubProjectItemField( args: UpdateProjectItemFieldArgs ): Promise>> { return updateProjectItemFieldValue(args) } async clearGitHubProjectItemField( args: ClearProjectItemFieldArgs ): Promise>> { return clearProjectItemFieldValue(args) } async updateGitHubIssueBySlug( args: UpdateIssueBySlugArgs ): Promise>> { return updateIssueBySlug(args) } async updateGitHubPullRequestBySlug( args: UpdatePullRequestBySlugArgs ): Promise>> { return updatePullRequestBySlug(args) } async updateGitHubIssueTypeBySlug( args: UpdateIssueTypeBySlugArgs ): Promise>> { return updateIssueTypeBySlug(args) } async addGitHubIssueCommentBySlug( args: AddIssueCommentBySlugArgs ): Promise>> { return addIssueCommentBySlug(args) } async updateGitHubIssueCommentBySlug( args: UpdateIssueCommentBySlugArgs ): Promise>> { return updateIssueCommentBySlug(args) } async deleteGitHubIssueCommentBySlug( args: DeleteIssueCommentBySlugArgs ): Promise>> { return deleteIssueCommentBySlug(args) } private getSetupHookTrustPayload( repo: Repo, scriptContentValue: string | undefined ): { contentHash: string; scriptContent: string } | undefined { const scriptContent = scriptContentValue?.trim() if (!scriptContent || repo.hookSettings?.commandSourcePolicy === 'local-only') { return undefined } return { contentHash: createHash('sha256').update(scriptContent).digest('hex'), scriptContent } } private getSharedSetupHookTrustPayload( repo: Repo, sharedSetupScript: string | undefined ): { contentHash: string; scriptContent: string } | undefined { if (repo.hookSettings?.commandSourcePolicy === 'local-only') { return undefined } return this.getSetupHookTrustPayload(repo, sharedSetupScript) } async getRepoHooks(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) if (repo.connectionId) { const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return { hasHooksFile: false, hooks: null, setupRunPolicy: getEffectiveSetupRunPolicy(repo), source: null } } try { const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) const hooks = result.isBinary ? null : parseOrcaYaml(result.content) return { hasHooksFile: Boolean(hooks), hooks, setupRunPolicy: getEffectiveSetupRunPolicy(repo), source: hooks ? 'orca.yaml' : null, setupTrust: this.getSharedSetupHookTrustPayload( repo, getDefaultTabCommandTrustContent(hooks) ) } } catch { return { hasHooksFile: false, hooks: null, setupRunPolicy: getEffectiveSetupRunPolicy(repo), source: null } } } const hasFile = hasHooksFile(repo.path) const hooks = getEffectiveHooks(repo) const sharedHooks = hasFile ? loadHooks(repo.path) : null const setupRunPolicy = getEffectiveSetupRunPolicy(repo) return { hasHooksFile: hasFile, hooks, setupRunPolicy, source: hasFile ? 'orca.yaml' : hooks ? 'legacy' : null, setupTrust: this.getSharedSetupHookTrustPayload( repo, getDefaultTabCommandTrustContent(sharedHooks) ) } } async checkRepoHooks(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } } if (repo.connectionId) { const fsProvider = getSshFilesystemProvider(repo.connectionId) // Why: callers cache "no hooks" as authoritative, so an unreadable repo must fail // closed with an error status (mirrors the hooks:check IPC handler) instead of // pinning a false "no setup script" verdict until the client remounts. if (!fsProvider) { return { status: 'error' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } } try { const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) if (result.isBinary) { return { status: 'ok' as const, hasHooks: false, hooks: null, mayNeedUpdate: false } } return { status: 'ok' as const, hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false } } catch (error) { return { status: isENOENT(error) ? ('ok' as const) : ('error' as const), hasHooks: false, hooks: null, mayNeedUpdate: false } } } const has = hasHooksFile(repo.path) const hooks = has ? loadHooks(repo.path) : null return { status: 'ok' as const, hasHooks: has, hooks, mayNeedUpdate: has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) } } async inspectRepoSetupScriptImports(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { return [] } return inspectSetupScriptImportCandidates(async (relativePath) => { const filePath = joinWorktreeRelativePath(repo.path, relativePath) if (repo.connectionId) { const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return null } try { const result = await fsProvider.readFile(filePath) return result.isBinary ? null : result.content } catch { return null } } try { return await readFile(filePath, 'utf-8') } catch (error) { if (!isENOENT(error)) { console.warn('[runtime] Failed to inspect setup script import candidate:', error) } return null } }) } async readRepoIssueCommand(repoSelector: string) { const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { return { localContent: null, sharedContent: null, effectiveContent: null, localFilePath: '', source: 'none' as const } } if (repo.connectionId) { const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return { localContent: null, sharedContent: null, effectiveContent: null, localFilePath: issueCommandPath, source: 'none' as const } } const localContent = await this.readRemoteIssueCommandOverride(fsProvider, issueCommandPath) const sharedContent = await this.readRemoteSharedIssueCommand(fsProvider, repo.path) const effectiveContent = localContent ?? sharedContent return { localContent, sharedContent, effectiveContent, localFilePath: issueCommandPath, source: localContent ? ('local' as const) : sharedContent ? ('shared' as const) : ('none' as const) } } return readIssueCommand(repo.path) } private async readRemoteIssueCommandOverride( fsProvider: IFilesystemProvider, issueCommandPath: string ): Promise { try { const result = await fsProvider.readFile(issueCommandPath) if (result.isBinary) { return null } return result.content.trim() || null } catch { return null } } private async readRemoteSharedIssueCommand( fsProvider: IFilesystemProvider, repoPath: string ): Promise { try { const result = await fsProvider.readFile(joinWorktreeRelativePath(repoPath, 'orca.yaml')) if (result.isBinary) { return null } return parseOrcaYaml(result.content)?.issueCommand?.trim() || null } catch { return null } } async writeRepoIssueCommand(repoSelector: string, content: string): Promise<{ ok: true }> { const repo = await this.resolveRepoSelector(repoSelector) if (isFolderRepo(repo)) { return { ok: true } } if (repo.connectionId) { const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return { ok: true } } const trimmed = content.trim() if (!trimmed) { await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { if (!isENOENT(error)) { throw error } }) return { ok: true } } await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) await this.ensureRemoteOrcaDirIgnored(fsProvider, repo.path) await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) return { ok: true } } writeIssueCommand(repo.path, content) return { ok: true } } private async ensureRemoteOrcaDirIgnored( fsProvider: IFilesystemProvider, repoPath: string, options: { required?: boolean } = {} ): Promise { const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore') let result: Awaited> try { result = await fsProvider.readFile(gitignorePath) } catch (error) { if (!isENOENT(error)) { if (options.required) { throw error } console.warn('[runtime] Could not inspect remote .gitignore for .orca', error) return } try { await fsProvider.writeFile(gitignorePath, '.orca\n') } catch (writeError) { if (options.required) { throw writeError } console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) } return } if (result.isBinary) { if (options.required) { throw new Error('Remote .gitignore is binary; cannot verify .orca is ignored') } return } if (/^\.orca\/?$/m.test(result.content)) { return } const separator = result.content.endsWith('\n') ? '' : '\n' try { await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) } catch (writeError) { if (options.required) { throw writeError } console.warn('[runtime] Could not update remote .gitignore to exclude .orca', writeError) } } async listManagedWorktrees( repoSelector?: string, limit = DEFAULT_WORKTREE_LIST_LIMIT ): Promise { if (!Number.isInteger(limit) || limit <= 0) { throw new Error('invalid_limit') } const resolved = await this.listResolvedWorktrees() const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null const checkoutPathsByRepoId = new Map() for (const worktree of resolved) { const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] checkoutPaths.push(worktree.path) checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) } const agentScratchMatchersByRepoId = new Map( (this.store?.getRepos() ?? []).map((repo) => [ repo.id, createAgentScratchWorktreePathMatcher([ repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? []) ]) ]) ) const worktrees = resolved.filter((worktree) => { if (repoId && worktree.repoId !== repoId) { return false } return this.isRuntimeWorktreeVisible( worktree, agentScratchMatchersByRepoId.get(worktree.repoId) ) }) return { worktrees: worktrees.slice(0, limit), totalCount: worktrees.length, truncated: worktrees.length > limit } } async listDetectedManagedWorktrees( repoSelector: string, connectionId?: string | null ): Promise { return this.listDetectedWorktreesForResolvedRepo( await this.resolveRepoSelectorForConnection(repoSelector, connectionId) ) } private async listDetectedWorktreesForResolvedRepo( repo: Repo ): Promise { const store = this.requireStore() if (isFolderRepo(repo)) { const worktrees = listRuntimeFolderWorkspaces(store, repo) const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) return { repoId: repo.id, authoritative: true, source: 'git', worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } let scan: RuntimeWorktreeScanResult try { scan = await this.listRepoWorktreesForResolution(repo) } catch { scan = { ok: false, worktrees: [] } } if (scan.ok) { this.pruneLineageForMissingRepoWorktrees(repo, scan.worktrees) } const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([ repo.path, ...scan.worktrees.map((worktree) => worktree.path) ]) const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` const meta = store.getWorktreeMeta(worktreeId) const worktree = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), hostId: meta?.hostId ?? getRepoExecutionHostId(repo) } const detectedWorktree = this.toRuntimeDetectedWorktree( repo, worktree, agentScratchWorktreePathMatcher ) if (scan.ok) { return detectedWorktree } return applyMetadataFallbackVisibility(detectedWorktree) }) return { repoId: repo.id, authoritative: scan.ok, source: scan.ok ? 'git' : 'metadata-fallback', worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } async teardownMissingManagedWorktreeTerminals( repoSelector: string, knownWorktreeIds: readonly string[], connectionId?: string | null ): Promise<{ stoppedWorktreeIds: string[] }> { const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId) // Why: killing PTYs must be proven against the host right now — a cached scan // (30s TTL) can still list a directory git already dropped, and the renderer // purges its state either way, so a stale miss strands those processes for good. this.invalidateWorktreeScanCacheForRepo(repo.id) // Why: rescanning by `id:` would re-resolve the already-resolved repo, and a // duplicate id across hosts makes that second lookup throw selector_ambiguous // even though the caller's selector was unique — losing the sweep entirely. const detected = await this.listDetectedWorktreesForResolvedRepo(repo) if (!detected.authoritative) { return { stoppedWorktreeIds: [] } } return stopMissingWorktreeTerminals( repo, knownWorktreeIds, detected.worktrees.map((worktree) => worktree.id), { runtime: this, getLocalProvider: () => this.getLocalProvider(), getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId), onPtyStopped: this.onPtyStopped ?? undefined } ) } private resolveRepoSelectorForConnection( repoSelector: string, connectionId?: string | null ): Promise { if (connectionId === undefined) { return this.resolveRepoSelector(repoSelector) } // Why: an explicit connection identity only *narrows* the selector; it must not // change the grammar. Matching the selector as a bare repo id would make // `path:`/`name:` selectors resolve to repo_not_found on this path alone. const wanted = connectionId?.trim() || null const matches = this.selectReposBySelector(repoSelector).filter( (repo) => (repo.connectionId?.trim() || null) === wanted ) if (matches.length !== 1) { throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found') } return Promise.resolve(matches[0]) } private isRuntimeWorktreeVisible( worktree: Worktree, agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher ): boolean { const repo = this.store?.getRepo(worktree.repoId) if (!repo || !this.store) { return true } return this.toRuntimeDetectedWorktree(repo, worktree, agentScratchWorktreePathMatcher).visible } private toRuntimeDetectedWorktree( repo: Repo, worktree: Worktree, agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher ): DetectedWorktree { const settings = this.store?.getSettings() if (!settings) { return { ...worktree, ownership: 'unknown-legacy', selectedCheckout: false, visible: true } } return toDetectedWorktree({ repo, worktree, meta: this.store?.getWorktreeMeta(worktree.id), settings, knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo), agentScratchWorktreePathMatcher }) } async showManagedWorktree(worktreeSelector: string) { return await this.resolveWorktreeSelector(worktreeSelector) } async scanWorkspacePorts(repoId?: string): Promise { return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId)) } async killWorkspacePort(args: WorkspacePortKillRequest): Promise { return killWorkspacePort(await this.getWorkspacePortProbes(args.repoId), args) } // Why: remote clients may invoke this over RPC, so the runtime derives // allowed worktree paths from its own store instead of trusting client paths. private async getWorkspacePortProbes(repoId?: string): Promise { const reposById = new Map( this.requireStore() .getRepos() .map((repo) => [repo.id, repo]) ) return filterWorkspacePortProbes( (await this.listResolvedWorktrees()).map((worktree) => ({ id: worktree.id, repoId: worktree.repoId, displayName: worktree.displayName, path: worktree.git.path, connectionId: reposById.get(worktree.repoId)?.connectionId ?? null })), repoId ) } async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> { const worktree = await this.resolveWorktreeSelector(worktreeSelector) // Why: sleep is renderer-initiated on desktop (it tears down tab state // before killing PTYs). The notifier tells the renderer to run its own // sleep flow so all cleanup happens in the correct order. this.notifier?.sleepWorktree(worktree.id) return { worktreeId: worktree.id } } async activateManagedWorktree( worktreeSelector: string, opts: { notifyClients?: boolean clientKind?: 'mobile' | 'runtime' navigation?: RuntimeNavigationTarget } = {} ): Promise<{ repoId: string worktreeId: string activated: boolean /** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no * renderer holds the sleeping records (headless `orca serve`), so nothing * woke — clients must not present the worktree's agents as resumed. */ sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' }> { this.assertGraphReady() const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) if (!repo) { throw new Error('repo_not_found') } const navigation = opts.navigation ?? (opts.notifyClients === false ? 'caller' : 'all') const targetsHost = navigationTargetsHost(navigation) const targetsClients = navigationTargetsClients(navigation) if (!targetsHost && this.store?.getWorktreeMeta(worktree.id)?.isUnread) { // Why: mobile/web session activation intentionally bypasses renderer // selection, so the runtime must acknowledge the unread state itself. this.store.setWorktreeMeta(worktree.id, { isUnread: false }) this.notifyWorktreesChanged(repo.id) } let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = 'not-applicable' if (targetsHost || targetsClients) { // Why: inactive worktree terminal panes are renderer-owned and may not have // live PTYs until the desktop activates the worktree and mounts them. if (targetsHost) { this.notifyHostActivateWorktree(repo.id, worktree.id) } if (targetsClients) { this.notifyClientsActivateWorktree(repo.id, worktree.id) } } if (!targetsHost) { // Why: mobile/web selection needs fresh session surfaces without forcing // every attached desktop renderer to navigate to the phone's workspace. this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id, { allowAttachedWindow: true }) await this.refreshMobileSessionPtyRecords() this.notifyMobileSessionTabsChanged(worktree.id) // Why: a phone open must also wake the worktree's slept agents (experimental // agent sleep). Only the host renderer holds the sleeping records + wake // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are // unaffected. Headless serve has no renderer to wake anything, so report // that explicitly instead of letting mobile assume the agents resumed. if (opts.clientKind === 'mobile') { if (this.getAvailableAuthoritativeWindow()) { this.notifier?.resumeSleepingAgents?.(worktree.id) sleepingAgentWake = 'requested' } else if ( // Why: sleeping records are partitioned by execution host; reading // only the local partition would miss slept agents on SSH-host // worktrees and skip the headless warning for them. Object.values( this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) .sleepingAgentSessionsByPaneKey ?? {} ).some((record) => record.worktreeId === worktree.id) ) { // Why: headless is only degraded when this worktree actually has a // persisted resume record. Ordinary mobile activation must not show // an unsupported warning merely because no desktop window is open. sleepingAgentWake = 'unsupported-headless' } } } return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } } private async buildStartupForDraft( repo: Repo, draft: string, requestedAgent?: TuiAgent ): Promise<{ agent: TuiAgent startup: WorktreeStartupLaunch draftPaste?: WorktreeStartupDraftPaste } | null> { if (!this.store) { return null } const content = draft.trim() if (!content) { return null } const settings = this.store.getSettings() const preferredAgent = requestedAgent ?? settings.defaultTuiAgent if (preferredAgent === 'blank') { // Why: `blank` is an explicit user preference to create a shell-only // workspace, so linked task drafts must not auto-pick a detected agent. return null } let agent = isTuiAgent(preferredAgent) && isTuiAgentEnabled(preferredAgent, settings.disabledTuiAgents) ? preferredAgent : null if (!agent) { let detected: string[] = [] try { // Why: startup-draft fallback can run from sparse runtime launch envs too. detected = repo.connectionId ? await detectRemoteAgents({ connectionId: repo.connectionId }) : await detectInstalledAgentsWithShellPathHydration() } catch { detected = [] } const typedDetected = detected.filter(isTuiAgent) agent = pickTuiAgent(null, typedDetected, settings.disabledTuiAgents) } if (!agent) { return null } // Why: a mobile client can run on Windows while the workspace shell is // Linux over SSH. Startup command quoting must target the shell that runs it. const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) const isRemote = repoIsRemote(repo) const queuedShell = resolveLocalWindowsAgentStartupShell({ platform: agentLaunchPlatform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) const draftLaunchPlan = buildAgentDraftLaunchPlan({ agent, draft: content, cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, shell: queuedShell, isRemote }) if (draftLaunchPlan) { return { agent, startup: { command: draftLaunchPlan.launchCommand, launchConfig: draftLaunchPlan.launchConfig, ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) } } } const startupPlan = buildAgentStartupPlan({ agent, prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) if (!startupPlan) { return null } return { agent, startup: { command: startupPlan.launchCommand, launchConfig: startupPlan.launchConfig, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}) }, draftPaste: { agent, content } } } private buildStartupForAgent( repo: Repo, agent: TuiAgent, prompt: string | undefined ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { if (!this.store) { throw new Error('runtime_unavailable') } const settings = this.store.getSettings() if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } // Why: CLI clients may target SSH runtimes from macOS/Windows, so quote for // the workspace shell rather than the client shell. const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo) const isRemote = repoIsRemote(repo) const queuedShell = resolveLocalWindowsAgentStartupShell({ platform: agentLaunchPlatform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform: agentLaunchPlatform, shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) if (!startupPlan) { throw new Error(`Could not build launch command for ${agent}.`) } return { agent, startup: { command: startupPlan.launchCommand, launchConfig: startupPlan.launchConfig, ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } : {}), ...(startupPlan.env ? { env: startupPlan.env } : {}) }, ...(startupPlan.followupPrompt ? { followup: { expectedProcess: startupPlan.expectedProcess, prompt: startupPlan.followupPrompt } } : {}) } } private markLocalWorkspaceTrustedForAgent(agent: TuiAgent, workspacePath: string): void { const preset = TUI_AGENT_CONFIG[agent].preflightTrust if (!preset) { return } try { if (preset === 'cursor') { markCursorWorkspaceTrusted(workspacePath) } else if (preset === 'copilot') { markCopilotFolderTrusted(workspacePath) } else if (preset === 'codex') { markCodexProjectTrusted(workspacePath) } } catch { // Best-effort: the user can still accept the agent trust prompt manually. } } private async markRemoteWorkspaceTrustedForAgent( agent: TuiAgent, connectionId: string, workspacePath: string ): Promise { const preset = TUI_AGENT_CONFIG[agent].preflightTrust if (!preset) { return } try { await markRemoteAgentWorkspaceTrusted({ preset, connectionId, workspacePath }) } catch { // Best-effort: the user can still accept the remote agent trust prompt manually. } } private recordCreatedWorktreeLineage( worktree: Pick, lineageResolution: WorktreeLineageResolution ): { lineage: WorktreeLineage | null workspaceLineage: WorkspaceLineage | null warnings: WorktreeLineageWarning[] } { const warnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : [] let lineage: WorktreeLineage | null = null let workspaceLineage: WorkspaceLineage | null = null if (lineageResolution.kind !== 'lineage') { return { lineage, workspaceLineage, warnings } } const childInstanceId = worktree.instanceId const parentInstanceId = lineageResolution.parent.instanceId const createdAt = Date.now() if ( lineageResolution.parent.type === 'worktree' && childInstanceId && parentInstanceId && this.store?.setWorktreeLineage ) { lineage = this.store.setWorktreeLineage(worktree.id, { worktreeId: worktree.id, worktreeInstanceId: childInstanceId, parentWorktreeId: lineageResolution.parent.worktree.id, parentWorktreeInstanceId: parentInstanceId, origin: lineageResolution.origin, capture: lineageResolution.capture, ...(lineageResolution.orchestrationRunId ? { orchestrationRunId: lineageResolution.orchestrationRunId } : {}), ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), ...(lineageResolution.coordinatorHandle ? { coordinatorHandle: lineageResolution.coordinatorHandle } : {}), ...(lineageResolution.createdByTerminalHandle ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } : {}), createdAt }) } else if (lineageResolution.parent.type === 'worktree') { warnings.push({ code: 'LINEAGE_PARENT_CONTEXT_MISSING', message: 'Worktree created, but Orca could not record lineage because instance identity was unavailable.', details: { childHasInstanceId: Boolean(childInstanceId), parentHasInstanceId: Boolean(parentInstanceId), storeSupportsLineage: Boolean(this.store?.setWorktreeLineage) } }) } if (childInstanceId && this.store?.setWorkspaceLineage) { workspaceLineage = this.store.setWorkspaceLineage({ childWorkspaceKey: worktreeWorkspaceKey(worktree.id), childInstanceId, parentWorkspaceKey: lineageResolution.parent.workspaceKey, parentInstanceId, origin: lineageResolution.origin, capture: lineageResolution.capture, ...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}), ...(lineageResolution.orchestrationRunId ? { orchestrationRunId: lineageResolution.orchestrationRunId } : {}), ...(lineageResolution.coordinatorHandle ? { coordinatorHandle: lineageResolution.coordinatorHandle } : {}), ...(lineageResolution.createdByTerminalHandle ? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle } : {}), createdAt }) } return { lineage, workspaceLineage, warnings } } private pasteStartupDraftWhenReady(handle: string, draft: WorktreeStartupDraftPaste): void { void this.waitForStartupDraftReady(handle, draft.agent) .then((ptyId) => { if (!ptyId) { console.warn('[worktree-create] agent did not become ready for draft paste') return } this.ptyController?.write( ptyId, `${BRACKETED_PASTE_BEGIN}${draft.content}${BRACKETED_PASTE_END}` ) }) .catch((error) => { console.warn('[worktree-create] failed to paste startup draft:', error) }) } private sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { void this.waitForStartupFollowupReady(handle, followup.expectedProcess) .then((ptyId) => { if (!ptyId) { console.warn('[worktree-create] agent did not become ready for follow-up prompt') return } this.ptyController?.write(ptyId, `${followup.prompt}\r`) }) .catch((error) => { console.warn('[worktree-create] failed to send startup follow-up prompt:', error) }) } private async createDefaultTabTerminals( worktreeSelector: string, worktreeId: string, defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined, surfacing: { surfaceOwner?: false } = {} ): Promise { if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) { return [] } const handles: string[] = [] for (const template of defaultTabs.tabs) { try { const command = template.command?.trim() const terminal = await this.createTerminal(worktreeSelector, { ...(template.title ? { title: template.title } : {}), ...(command && defaultTabs.runCommands ? { command } : {}), ...surfacing }) handles.push(terminal.handle) if (template.color && terminal.tabId) { await this.setMobileSessionTabProps(`id:${worktreeId}`, { tabId: terminal.tabId, color: template.color }) } } catch (error) { console.warn(`[worktree-create] Failed to create default tab for ${worktreeId}:`, error) } } return handles } private async provisionManagedWorktreeTerminals(args: { worktreeSelector: string worktreeId: string worktreePath: string setup?: CreateWorktreeResult['setup'] defaultTabs?: CreateWorktreeResult['defaultTabs'] primaryTerminalHandle?: string | null hasStartupTerminal: boolean setupCommandPlatform: 'windows' | 'posix' observeSetupCompletion?: boolean // Why: when the agent startup is sequenced to wait for setup // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds // the setup command. Pass that wrapped command through so the Setup tab runs // the same script the agent is waiting on instead of a bare runner. wrappedSetupCommand?: string // Why: a workspace provisioned in the background must not pull the sidebar // to itself; the user never asked to look at these tabs. surfaceOwner?: false }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { if (!this.ptyController?.spawn) { return { setupSpawned: false, setupTerminalHandle: null } } const surfacing = ownerSurfacing(args.surfaceOwner !== false) let setupSpawned = false let setupTerminalHandle: string | null = null try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, args.worktreeId, args.defaultTabs, surfacing ) let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null const setupLaunchMode = ( this.requireStore().getSettings() as Partial< Pick > ).setupScriptLaunchMode ?? 'new-tab' if (!args.hasStartupTerminal && !primaryTerminalHandle) { const terminal = await this.createTerminal(args.worktreeSelector, surfacing) primaryTerminalHandle = terminal.handle } if (args.setup) { const completionToken = args.observeSetupCompletion && !args.wrappedSetupCommand ? randomUUID() : null const observedCommand = completionToken ? buildObservedSetupCommand( args.setup.runnerScriptPath, args.setupCommandPlatform, completionToken, args.setup.shell ) : null const setupCommand = args.wrappedSetupCommand ?? observedCommand?.command ?? buildSetupRunnerCommand( args.setup.runnerScriptPath, args.setupCommandPlatform, args.setup.shell ) const setupEnv = { ...args.setup.envVars, ...observedCommand?.env } const shouldSplitSetup = primaryTerminalHandle && (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') const setupTerminal = await (shouldSplitSetup ? this.splitTerminal(primaryTerminalHandle!, { direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', command: setupCommand, env: setupEnv, activate: false, ...surfacing }) : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, env: setupEnv, ...surfacing })) setupTerminalHandle = setupTerminal.handle setupSpawned = true const ptyId = this.getLivePtyForHandle(setupTerminal.handle)?.pty.ptyId if (completionToken && ptyId) { this.setupCompletionTokenByPtyId.set(ptyId, completionToken) } } } catch (err) { const message = err instanceof Error ? err.message : String(err) console.warn( `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` ) } return { setupSpawned, setupTerminalHandle } } private async waitForStartupFollowupReady( handle: string, expectedProcess: string ): Promise { const livePty = this.getLivePtyForHandle(handle) const ptyId = livePty?.pty.ptyId if (!ptyId || !this.ptyController) { return null } for (let attempt = 0; attempt < 30; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => setTimeout(resolve, 150)) } try { const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) if (isExpectedAgentProcess(foregroundProcess, expectedProcess)) { return ptyId } if (attempt >= 4 && !isShellProcess(foregroundProcess ?? '')) { const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(ptyId).catch(() => false)) ?? false if (hasChildProcesses) { return ptyId } } } catch { // Ignore transient PTY inspection failures and keep polling. } } return null } private waitForStartupDraftReady(handle: string, agent: TuiAgent): Promise { const livePty = this.getLivePtyForHandle(handle) const ptyId = livePty?.pty.ptyId if (!ptyId) { return Promise.resolve(null) } const readySignal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' return new Promise((resolve) => { let settled = false const scanner = createDraftPasteReadyScanner(readySignal) let quietTimer: NodeJS.Timeout | null = null let hardTimer: NodeJS.Timeout | null = null let unsubscribe: (() => void) | null = null const finish = (value: string | null): void => { if (settled) { return } settled = true if (quietTimer) { clearTimeout(quietTimer) } if (hardTimer) { clearTimeout(hardTimer) } unsubscribe?.() resolve(value) } const armQuietTimer = (): void => { if (quietTimer) { clearTimeout(quietTimer) } quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS) } const observeData = (data: string): void => { const { ready, armQuietTimer: shouldArm } = scanner.observe(data) if (ready) { finish(ptyId) return } if (shouldArm) { armQuietTimer() } } unsubscribe = this.subscribeToTerminalData(ptyId, observeData) const replay = this.recentPtyOutputById.get(ptyId)?.read() if (replay) { observeData(replay) } hardTimer = setTimeout(() => finish(null), DRAFT_PASTE_READY_TIMEOUT_MS) }) } async prefetchManagedWorktreeCreateBase(args: { repoSelector: string baseBranch?: string }): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(args.repoSelector) await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime: this }) } async createManagedWorktree(args: { repoSelector: string name: string baseBranch?: string compareBaseRef?: string branchNameOverride?: string linkedIssue?: number | null linkedPR?: number | null linkedLinearIssue?: string linkedLinearIssueWorkspaceId?: string | null linkedLinearIssueOrganizationUrlKey?: string | null linkedGitLabMR?: number | null linkedGitLabIssue?: number | null linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null comment?: string displayName?: string telemetrySource?: WorkspaceCreateTelemetrySource workspaceStatus?: string manualOrder?: number sparseCheckout?: { directories: string[]; presetId?: string } pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean createdWithAgent?: TuiAgent startupAgent?: TuiAgent startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste lineage?: WorktreeLineageInput }): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const repo = await this.resolveRepoSelector(args.repoSelector) const createSettings = this.store.getSettings() const requestedAgent = args.startupAgent ?? args.createdWithAgent const requestedAgentEnabled = requestedAgent !== undefined ? isTuiAgentEnabled(requestedAgent, createSettings.disabledTuiAgents) : false if ((args.startup || args.startupAgent) && requestedAgent && !requestedAgentEnabled) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } if ( args.startup && args.startupDraftPaste && !isTuiAgentEnabled(args.startupDraftPaste.agent, createSettings.disabledTuiAgents) ) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } const agentStartup = !args.startup && args.startupAgent ? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt) : null const draftStartup = !args.startup && !agentStartup && args.startupDraft ? await this.buildStartupForDraft(repo, args.startupDraft, requestedAgent) : null const effectiveStartup = args.startup ?? agentStartup?.startup ?? draftStartup?.startup const effectiveStartupFollowup = agentStartup?.followup const effectiveCreatedWithAgent = args.startup ? args.createdWithAgent : (agentStartup?.agent ?? draftStartup?.agent ?? (requestedAgentEnabled ? requestedAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste if (isFolderRepo(repo)) { const now = Date.now() const settings = createSettings const instanceId = randomUUID() const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId) const meta = this.store.setWorktreeMeta(worktreeId, { instanceId, ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), displayName: args.displayName?.trim() || args.name, lastActivityAt: now, createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'runtime', orcaCreationWorkspaceLayout: { path: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } : {}), ...(args.linkedLinearIssueOrganizationUrlKey !== undefined ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } : {}), ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), ...(args.linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } : {}), ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) }) const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) this.emitWorktreeLifecycle({ kind: 'created', worktreeId: worktree.id, path: worktree.path, branch: worktree.branch }) const shouldActivate = args.activate === true || args.runHooks === true let warning: string | undefined let didSpawnStartup = false let startupTerminal: CreateWorktreeResult['startupTerminal'] if (effectiveStartup && this.ptyController?.spawn) { try { const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent if (startupTrustAgent) { this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktree.path) } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: effectiveStartup.command, env: effectiveStartup.env, ...(effectiveStartup.launchConfig ? { launchConfig: effectiveStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), ...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}), startupCommandDelivery: effectiveStartup.startupCommandDelivery, telemetry: effectiveStartup.telemetry, ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) } if (effectiveStartupFollowup) { this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) } didSpawnStartup = true startupTerminal = { spawned: true, handle: terminal.handle, ...(terminal.tabId ? { tabId: terminal.tabId } : {}), ...(terminal.paneKey ? { paneKey: terminal.paneKey } : {}), ...(terminal.ptyId ? { ptyId: terminal.ptyId } : {}), surface: 'background' } } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = `Failed to create the startup terminal for ${worktree.path}: ${message}` console.warn(`[worktree-create] ${warning}`) } } if (shouldActivate) { if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree(repo.id, worktree.id, undefined, effectiveStartup) } else { this.notifyActivateWorktree(repo.id, worktree.id) } } else if (this.ptyController?.spawn && !didSpawnStartup) { try { await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning ? `${warning} Also failed to create the initial terminal for ${worktree.path}: ${message}` : `Failed to create the initial terminal for ${worktree.path}: ${message}` console.warn(`[worktree-create] ${warning}`) } } return { worktree: { ...worktree, parentWorktreeId: null, childWorktreeIds: [], lineage: null, git: { path: worktree.path, head: worktree.head, branch: worktree.branch, isBare: worktree.isBare, isMainWorktree: worktree.isMainWorktree } }, ...(startupTerminal ? { startupTerminal } : {}), ...(warning ? { warning } : {}) } } const lineageInput = args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput) if (repo.connectionId) { const result = await this.createManagedRemoteWorktree(repo, { ...args, activate: args.activate, ...(effectiveStartup ? { startup: effectiveStartup } : {}), ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) this.emitWorktreeLifecycle({ kind: 'created', worktreeId: result.worktree.id, path: result.worktree.path, branch: result.worktree.branch }) return { ...result, worktree: { ...result.worktree, parentWorktreeId: recordedLineage.lineage?.parentWorktreeId ?? null, childWorktreeIds: result.worktree.childWorktreeIds ?? [], lineage: recordedLineage.lineage, workspaceLineage: recordedLineage.workspaceLineage }, ...(lineageInput ? { lineage: recordedLineage.lineage, workspaceLineage: recordedLineage.workspaceLineage, warnings: recordedLineage.warnings } : {}) } } const settings = createSettings const worktreePathSettings = getWorktreePathSettings(repo, settings) const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const hasLocalWorktreeGitOptions = hasLocalGitOptions(localWorktreeGitOptions) const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions ? [localWorktreeGitOptions] : [] const addProjectGitOptions = (options?: AddWorktreeOptions): AddWorktreeOptions | undefined => { if (!hasLocalWorktreeGitOptions) { return options } return { ...options, ...localWorktreeGitOptions } } const hostedReviewExecutionContext = this.getHostedReviewExecutionOptions(repo) let effectiveRequestedName = args.name const requestedDisplayName = args.displayName?.trim() || undefined const sanitizedName = sanitizeWorktreeName(args.name) let effectiveSanitizedName = sanitizedName // Why: explicit branches and non-username prefix modes never consume this // value; skipping the probes preserves the exact generated branch name. const username = !args.branchNameOverride && settings.branchPrefix === 'git-username' ? await resolveLocalGitUsername(repo.path) : '' const baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => hasLocalWorktreeGitOptions ? resolveDefaultBaseRefWithLocalGit(localGitExecOptions) : getBaseRefDefault(repo.path), isBaseUsable: async (baseBranchCandidate) => { const remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, baseBranchCandidate, ...localWorktreeGitOptionArgs ) if (remoteTrackingBase) { if ( await this.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) ) { return true } return hasLocalWorktreeBaseRef( repo.path, baseBranchCandidate, hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} ) } return hasLocalWorktreeBaseRef( repo.path, baseBranchCandidate, hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} ) } }) if (!baseBranch) { // Why: a null default means no suitable ref exists; fail clearly instead // of handing Git a fabricated origin/main ref. throw new Error( 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) } const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings) // Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the // distro filesystem through computeWorkspaceRoot. If home lookup fails, // still validate against the effective workspace dir. let branchName = '' let checkoutExistingBranch = false let selectedExistingLocalBranchName: string | null = null let branchConflictKind: 'local' | 'remote' | null = null let worktreePath = '' let worktreePathResolved = false // Why: runtime/mobile create-from-review callers should get a new workspace // even when the PR branch or review branch name is already in use. for (let suffix = 1; suffix <= WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) { effectiveSanitizedName = getWorktreeCreateCandidate(sanitizedName, suffix) effectiveRequestedName = args.name.trim() ? getWorktreeCreateCandidate(args.name, suffix) : effectiveSanitizedName branchName = await resolveCreateBranchName( repo.path, selectedExistingLocalBranchName ?? getBranchNameOverrideCandidate(args.branchNameOverride, suffix), effectiveSanitizedName, settings, username, localWorktreeGitOptions ) checkoutExistingBranch = await canCheckoutExistingLocalBranch( repo.path, branchName, baseBranch, ...localWorktreeGitOptionArgs ) if (checkoutExistingBranch && !selectedExistingLocalBranchName) { // Why: once a user-selected branch is safe to reuse, path retries should // keep that branch exact instead of creating a sibling branch. selectedExistingLocalBranchName = branchName } branchConflictKind = checkoutExistingBranch ? null : await getBranchConflictKind( repo.path, branchName, baseBranch, ...localWorktreeGitOptionArgs ) const allowedPushTargetRemoteConflict = branchConflictKind && isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args) let selectedReviewConflictMatched = false if (branchConflictKind) { if (allowedPushTargetRemoteConflict) { let existingPR: Awaited> | null = null const selectedReview = getSelectedReviewBranch(args) if (selectedReview?.provider === 'github') { try { existingPR = await getLocalGitHubPrForBranch( repo.path, branchName, localWorktreeGitOptions ) } catch { // Retry with a suffixed branch when selected review verification is unavailable. } if (isMatchingSelectedGitHubPr(existingPR, args, branchName)) { branchConflictKind = null selectedReviewConflictMatched = true } } else if (selectedReview) { const hostedReview = await getSelectedHostedReviewForBranch( repo, branchName, args, hostedReviewExecutionContext ).catch(() => null) if (hostedReview?.matchesSelected) { branchConflictKind = null selectedReviewConflictMatched = true } } } if (branchConflictKind) { continue } } if (!checkoutExistingBranch && !selectedReviewConflictMatched) { let existingPR: Awaited> | null = null try { existingPR = await getLocalGitHubPrForBranch( repo.path, branchName, localWorktreeGitOptions ) } catch { // Why: GitHub reachability should not block creating a suffixed // workspace; git conflicts still decide whether this candidate works. } if (existingPR && !isMatchingSelectedGitHubPr(existingPR, args, branchName)) { continue } } worktreePath = ensurePathWithinWorkspace( computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings), workspaceRoot ) if (!(await pathExists(worktreePath))) { worktreePathResolved = true break } } if (!worktreePathResolved) { if (branchConflictKind) { throw new Error( `Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.` ) } throw new Error( `Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.` ) } let remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, baseBranch, ...localWorktreeGitOptionArgs ) if (remoteTrackingBase) { const hadRemoteTrackingBaseRef = await this.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) const hasLocalBaseRef = hadRemoteTrackingBaseRef || (await hasLocalWorktreeBaseRef( repo.path, baseBranch, hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} )) if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) { remoteTrackingBase = null } else { const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs ) if (!refreshResult.ok && !hadRemoteTrackingBaseRef) { // Why: only block creation when the refresh failed AND there is no // usable local base ref to fall back on. If a local remote-tracking ref // already exists, `git worktree add` can create from it — a possibly // stale but valid base — so a transient offline/auth failure must not // make the workspace uncreatable. The compare-to-base view reflects any // drift once the remote is reachable again. throw new Error( `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` ) } if ( !hadRemoteTrackingBaseRef && !(await this.hasRemoteTrackingRef( repo.path, remoteTrackingBase, ...localWorktreeGitOptionArgs )) ) { throw new Error(`Base ref "${baseBranch}" was not found after fetching.`) } } } else if ( !(await hasLocalWorktreeBaseRef( repo.path, baseBranch, hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {} )) ) { // Why: local bases keep legacy best-effort fetch behavior. Verified PR // SHA bases already have the commit object needed by `git worktree add`. try { await this.fetchRemoteWithCache(repo.path, 'origin', ...localWorktreeGitOptionArgs) } catch { // Why: belt-and-suspenders. fetchRemoteWithCache already logs and does // not throw; the outer try/catch guarantees create-path tolerance even // if future refactors change that contract. } } const sparseDirectories = args.sparseCheckout ? normalizeSparseDirectories(args.sparseCheckout.directories) : [] if (args.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } let preparedPushTarget: GitPushTarget | undefined if (args.pushTarget) { // Why: fork-PR worktrees created through a remote runtime need the same // upstream target setup as local desktop creates, or Push would publish // to the wrong remote after the client/server split. preparedPushTarget = await prepareWorktreePushTarget( repo.path, args.pushTarget, this.store, repo.id, localWorktreeGitOptions ) } const suggestLocalBaseRefUpdate = !settings.refreshLocalBaseRefOnWorktreeCreate && !settings.localBaseRefSuggestionDismissed && Boolean(remoteTrackingBase) const remoteTrackingBaseOption = remoteTrackingBase ? { remoteTrackingBase } : undefined const existingBranchOption = { checkoutExistingBranch, ...remoteTrackingBaseOption, ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) } const defaultAddWorktreeOption = addProjectGitOptions() const addResult: AddWorktreeResult = (await (sparseDirectories.length > 0 ? checkoutExistingBranch ? addSparseWorktree( repo.path, worktreePath, branchName, sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, addProjectGitOptions(existingBranchOption) ) : suggestLocalBaseRefUpdate ? addSparseWorktree( repo.path, worktreePath, branchName, sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) ) : remoteTrackingBaseOption ? addSparseWorktree( repo.path, worktreePath, branchName, sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, addProjectGitOptions(remoteTrackingBaseOption) ) : defaultAddWorktreeOption ? addSparseWorktree( repo.path, worktreePath, branchName, sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, defaultAddWorktreeOption ) : addSparseWorktree( repo.path, worktreePath, branchName, sparseDirectories, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate ) : checkoutExistingBranch ? addWorktree( repo.path, worktreePath, branchName, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, addProjectGitOptions(existingBranchOption) ) : suggestLocalBaseRefUpdate ? addWorktree( repo.path, worktreePath, branchName, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) ) : remoteTrackingBaseOption ? addWorktree( repo.path, worktreePath, branchName, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, addProjectGitOptions(remoteTrackingBaseOption) ) : defaultAddWorktreeOption ? addWorktree( repo.path, worktreePath, branchName, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate, false, defaultAddWorktreeOption ) : addWorktree( repo.path, worktreePath, branchName, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate ))) ?? {} let configuredPushTarget: GitPushTarget | undefined if (preparedPushTarget) { configuredPushTarget = await configureCreatedWorktreePushTarget( worktreePath, branchName, preparedPushTarget, localWorktreeGitOptions ) } const gitWorktrees = hasLocalWorktreeGitOptions ? await listWorktrees(repo.path, localWorktreeGitOptions) : await listWorktrees(repo.path) // Why: Git may canonicalize a symlinked create path; its exact branch identifies the listed row. const created = findCreatedWorktree(gitWorktrees, worktreePath, branchName) if (!created) { throw new Error('Worktree created but not found in listing') } const worktreeId = `${repo.id}::${created.path}` const now = Date.now() // Why: PR/MR-created worktrees can start from a head ref/SHA while Source // Control must compare against the review target branch. const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch const displayNameMeta = requestedDisplayName ? { displayName: requestedDisplayName } : shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) ? { displayName: effectiveRequestedName } : {} const meta = this.store.setWorktreeMeta(worktreeId, { // Why: worktree IDs are path-derived. If a path is deleted outside Orca // and later recreated, creation must mint a fresh instance identity so // stale lineage records tied to the old occupant fail validation. instanceId: randomUUID(), ...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo), lastActivityAt: now, // See createRemoteWorktree: createdAt grants the new worktree a grace // window in Recent sort so ambient PTY bumps in OTHER worktrees can't // push it down before the user has had a chance to notice it. Smart-sort // uses max(lastActivityAt, createdAt + CREATE_GRACE_MS). createdAt: now, orcaCreatedAt: now, orcaCreationSource: 'runtime', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...displayNameMeta, baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), ...(sparseDirectories.length > 0 ? { sparseDirectories, sparseBaseRef: metadataBaseRef, sparsePresetId: args.sparseCheckout?.presetId } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } : {}), ...(args.linkedLinearIssueOrganizationUrlKey !== undefined ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } : {}), ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), ...(args.linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } : {}), ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent ? { pendingFirstAgentMessageRename: true } : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) }) const worktree = { ...mergeWorktree(repo.id, created, meta), hostId: meta.hostId ?? getRepoExecutionHostId(repo) } const { lineage, workspaceLineage, warnings: lineageWarnings } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) const symlinkPaths = repo.symlinkPaths ?? [] if (symlinkPaths.length > 0) { await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) } // Why: project-level `orca.yaml` shared directories add to (never replace) the // per-user setting, so a repo's shared dirs reach every teammate (issue #10451). const sharedDirectories = await resolveWorktreeSharedDirectories( repo.path, localWorktreeGitOptions ) if (sharedDirectories.length > 0) { await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) } // Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics // (never symlink) so each worktree owns its files. Paths already linked above are skipped. const worktreeIncludePaths = await resolveWorktreeIncludePaths( repo.path, localWorktreeGitOptions ) let includeCopyWarning: string | undefined if (worktreeIncludePaths.length > 0) { const skippedIncludePaths = await createWorktreeCopiedPaths( repo.path, created.path, worktreeIncludePaths ) includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) if (includeCopyWarning) { console.warn(`[worktree-include] ${includeCopyWarning}`) } } let setup: CreateWorktreeResult['setup'] let warning: string | undefined = includeCopyWarning // Why: CLI-created worktrees do not have a renderer preview to mismatch // against. Trust is granted by the direct CLI invocation (`--run-hooks`), // so loading the setup hook from the created worktree is intentional here. const yamlHooks = loadHooks(worktreePath) const hooks = getEffectiveHooks(repo, worktreePath) // Why: setupDecision lets mobile/CLI callers control whether the setup // script runs. 'skip' suppresses it, 'run' forces it, 'inherit' (default) // defers to the repo's orca.yaml setupRunPolicy. runHooks === true maps // to 'run' for backwards compatibility with the desktop create flow. const effectiveDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') let defaultTabs: CreateWorktreeResult['defaultTabs'] try { defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, effectiveDecision) } catch (error) { console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error) defaultTabs = yamlHooks?.defaultTabs ? { tabs: yamlHooks.defaultTabs, runCommands: false } : undefined } const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision) // Why: the in-process hook uses a hardcoded cmd/bash shell, so it can only run // when nothing downstream is able to launch the shell-aware runner script. let didStartInProcessSetupHook = false if (shouldRunSetup && hooks?.scripts.setup) { const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) || Boolean(this.ptyController?.spawn) if (shouldUseSetupRunner) { try { // Why: setup+startup must share the terminal runner path even without // a renderer window, so the startup shell can wait on setup completion // and windowless creates resolve the same Windows setup shell. const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0] // Why: both trailing args are optional — the shell is undefined off Windows. setup = createSetupRunnerScript( repo, worktreePath, hooks.scripts.setup, runtimeTarget, resolveSetupRunnerShell(settings) ) } catch (error) { // Why: the git worktree is already real at this point. If runner // generation fails, keep creation successful and surface the problem in // logs rather than pretending the worktree was never created. console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error) } } else { didStartInProcessSetupHook = true void runHook( 'setup', worktreePath, repo, worktreePath, this.getLocalGitExecutionOptionArgs(repo)[0] ).then((result) => { if (!result.success) { console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output) } }) } } else if (hooks?.scripts.setup && effectiveDecision !== 'skip') { // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. const setupSkipped = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` warning = warning ? `${warning} Also ${setupSkipped}` : setupSkipped console.warn(`[hooks] ${setupSkipped}`) } this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) // Why: the filesystem-auth layer maintains a separate cache of registered // worktree roots used by git IPC handlers (branchCompare, diff, status, etc.) // to authorize paths. Without invalidating it here, CLI-created worktrees // are not recognized and all git operations fail with "Access denied: // unknown repository or worktree path". invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let didSpawnStartup = false // Why: tracks whether runtime itself launched the setup script (via // provisionManagedWorktreeTerminals). When true, renderer activation and the // RPC return value must omit setup so the client does not spawn it a second // time. Mirrors the wait-for-agent setup contract from #6298. let didSpawnSetup = false let setupTerminalHandle: string | null = null let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null let sequencedStartup = effectiveStartup let wrappedSetupCommandStr: string | undefined if (effectiveStartup && setup?.waitForAgentStartup === true) { const platform = getSetupRunnerCommandPlatformForLaunch( setup, process.platform === 'win32' ? 'windows' : 'posix' ) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: effectiveStartup.command, platform, shell: setup.shell }) sequencedStartup = { ...effectiveStartup, command: sequenced.startupCommand, ...(sequenced.startupEnv ? { env: { ...effectiveStartup.env, ...sequenced.startupEnv } } : {}) } wrappedSetupCommandStr = sequenced.setupCommand } if (sequencedStartup && this.ptyController?.spawn) { try { // Why: automation startup must not depend on a renderer TerminalPane // mounting. Runtime-spawned PTYs run immediately and the UI adopts the // session later, matching `orca terminal create` background semantics. const startupTrustAgent = effectiveDraftPaste?.agent ?? effectiveCreatedWithAgent if (startupTrustAgent) { this.markLocalWorkspaceTrustedForAgent(startupTrustAgent, worktreePath) } const terminal = await this.createTerminal(`id:${worktree.id}`, { command: sequencedStartup.command, ...(setup && effectiveStartup ? { claudeAgentTeamsSourceCommand: effectiveStartup.command } : {}), env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry, ...ownerSurfacing(shouldActivate) }) if (effectiveDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste) } if (effectiveStartupFollowup) { this.sendStartupFollowupWhenReady(terminal.handle, effectiveStartupFollowup) } didSpawnStartup = true startupTerminalHandle = terminal.handle startupTerminalTabId = terminal.tabId ?? null startupTerminalPaneKey = terminal.paneKey ?? null startupTerminalPtyId = terminal.ptyId ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning ? `${warning} Also failed to create the startup terminal for ${worktreePath}: ${message}` : `Failed to create the startup terminal for ${worktreePath}: ${message}` console.warn(`[worktree-create] ${warning}`) } } if (shouldActivate) { // Why: plain CLI creates should not steal the user's current workspace. // Explicit activation and hook-running still use renderer activation so // the user can watch prompts/output in a visible pane. const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(setup || defaultTabs) if (runtimeWillProvisionTerminals) { // Why: once runtime spawned the startup PTY, renderer activation may see // an existing terminal and skip setup/default tabs. Await provisioning so // a failed setup spawn falls back to renderer activation (which still // carries the wrapped command for retry); #6298's wait-for-setup // guarantee is enforced by the shell marker, not by spawn timing. const provisioned = await this.provisionManagedWorktreeTerminals({ worktreeSelector: `id:${worktree.id}`, worktreeId: worktree.id, worktreePath, ...(setup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // Setup tab runs the same script the sequenced agent waits on. ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) didSpawnSetup = provisioned.setupSpawned setupTerminalHandle = provisioned.setupTerminalHandle } // Why: when runtime spawned setup, omit it from activation. When setup // spawn failed, fall through with the wrapped command so renderer // activation retries it. const activationSetup = didSpawnSetup ? undefined : setup ? { ...setup, ...(didSpawnStartup && wrappedSetupCommandStr ? { command: wrappedSetupCommandStr } : {}) } : undefined const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : defaultTabs if (effectiveStartup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, worktree.id, activationSetup, effectiveStartup, activationDefaultTabs ) } else { this.notifyActivateWorktree( repo.id, worktree.id, activationSetup, undefined, activationDefaultTabs ) } } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { // Why: inactive terminal materialization matches normal worktree creation, // but setup/default tab failures must not gate automation dispatch. const provisioning = this.provisionManagedWorktreeTerminals({ worktreeSelector: `id:${worktree.id}`, worktreeId: worktree.id, worktreePath, ...(setup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), surfaceOwner: false }) // Why: runtime owns setup spawning here, so the RPC result must omit setup // to keep the headless/mobile caller from launching it a second time. if (args.awaitTerminalProvisioning) { const provisioned = await provisioning didSpawnSetup = provisioned.setupSpawned setupTerminalHandle = provisioned.setupTerminalHandle } else { void provisioning if (setup) { didSpawnSetup = true } } } else if (this.ptyController?.spawn) { try { await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning ? `${warning} Also failed to create the initial terminal for ${worktreePath}: ${message}` : `Failed to create the initial terminal for ${worktreePath}: ${message}` console.warn(`[worktree-create] ${warning}`) } } const returnedSetup = didSpawnSetup ? undefined : setup ? { ...setup, ...(didSpawnStartup && wrappedSetupCommandStr ? { command: wrappedSetupCommandStr } : {}) } : undefined this.emitWorktreeLifecycle({ kind: 'created', worktreeId: worktree.id, path: worktree.path, branch: worktree.branch }) return { worktree: { ...worktree, parentWorktreeId: lineage?.parentWorktreeId ?? null, childWorktreeIds: [], lineage, workspaceLineage, git: created }, ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), ...(returnedSetup ? { setup: returnedSetup } : {}), ...(args.awaitTerminalProvisioning ? { setupReceipt: { requested: effectiveDecision, hookFound: Boolean(hooks?.scripts.setup), startupPolicy: setup?.waitForAgentStartup ? ('wait-for-setup' as const) : ('start-immediately' as const), state: !hooks?.scripts.setup ? ('not_configured' as const) : effectiveDecision === 'skip' || !shouldRunSetup ? ('skipped' as const) : // Why: the in-process hook is already executing, so reporting // spawn_failed would strand callers that retry on it. didSpawnSetup || didStartInProcessSetupHook ? ('running' as const) : ('spawn_failed' as const), ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) } } : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh ? { localBaseRefRefresh: addResult.localBaseRefRefresh } : {}), ...(addResult.localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } : {}), ...(didSpawnStartup && startupTerminalHandle ? { startupTerminal: { spawned: true, handle: startupTerminalHandle, ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), surface: 'background' as const } } : {}) } } private async createManagedRemoteWorktree( repo: Repo, args: { name: string baseBranch?: string compareBaseRef?: string branchNameOverride?: string linkedIssue?: number | null linkedPR?: number | null linkedLinearIssue?: string linkedLinearIssueWorkspaceId?: string | null linkedLinearIssueOrganizationUrlKey?: string | null linkedGitLabMR?: number | null linkedGitLabIssue?: number | null linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null linkedWorkItem?: WorkspaceLinkedItem | null linkedTaskSourceContext?: TaskSourceContext | null comment?: string displayName?: string workspaceStatus?: string manualOrder?: number sparseCheckout?: { directories: string[]; presetId?: string } pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' awaitTerminalProvisioning?: boolean observeSetupCompletion?: boolean createdWithAgent?: TuiAgent pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance cliProvenance?: CliWorkspaceProvenance startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup startupDraftPaste?: WorktreeStartupDraftPaste } ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } // Why: runtime/mobile callers do not own a renderer BrowserWindow, but the // SSH create helper only uses it for progress and change notifications. // Runtime emits those through RuntimeNotifier after the create succeeds. const headlessWindow = { isDestroyed: () => false, webContents: { send: () => undefined } } as unknown as BrowserWindow const result = await createRemoteWorktree( { repoId: repo.id, name: args.name, ...(args.displayName ? { displayName: args.displayName } : {}), ...(args.baseBranch ? { baseBranch: args.baseBranch } : {}), ...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}), ...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}), ...(args.runHooks ? { setupDecision: 'run' as const } : {}), ...(!args.runHooks && args.setupDecision ? { setupDecision: args.setupDecision } : {}), ...(args.sparseCheckout ? { sparseCheckout: args.sparseCheckout } : {}), ...(args.linkedIssue != null ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR != null ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } : {}), ...(args.linkedLinearIssueOrganizationUrlKey !== undefined ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } : {}), ...(args.linkedGitLabMR != null ? { linkedGitLabMR: args.linkedGitLabMR } : {}), ...(args.linkedGitLabIssue != null ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), ...(args.linkedBitbucketPR != null ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), ...(args.linkedAzureDevOpsPR != null ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } : {}), ...(args.linkedGiteaPR != null ? { linkedGiteaPR: args.linkedGiteaPR } : {}), ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), ...(args.linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext: args.linkedTaskSourceContext } : {}), ...(args.pushTarget ? { pushTarget: args.pushTarget } : {}), ...(args.workspaceStatus ? { workspaceStatus: args.workspaceStatus as never } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true ? { pendingFirstAgentMessageRename: true } : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}) }, repo, this.store as unknown as Store, headlessWindow ) if (args.comment !== undefined) { this.store.setWorktreeMeta(result.worktree.id, { comment: args.comment }) result.worktree.comment = args.comment } this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repo.id) this.notifyWorktreesChanged(repo.id) const shouldActivate = args.activate === true || args.runHooks === true let warning = result.warning let didSpawnStartup = false // Why: same no-double-spawn contract as the local path — once runtime // provisions setup, omit it from activation and the RPC result. let didSpawnSetup = false let setupTerminalHandle: string | null = null let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null let startupTerminalPtyId: string | null = null let sequencedStartup = args.startup let wrappedSetupCommandStr: string | undefined if (args.startup && result.setup?.waitForAgentStartup === true) { const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix') const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: result.setup.runnerScriptPath, startupCommand: args.startup.command, platform, shell: result.setup.shell }) sequencedStartup = { ...args.startup, command: sequenced.startupCommand, ...(sequenced.startupEnv ? { env: { ...args.startup.env, ...sequenced.startupEnv } } : {}) } wrappedSetupCommandStr = sequenced.setupCommand } if (sequencedStartup && this.ptyController?.spawn) { try { const startupTrustAgent = args.startupDraftPaste?.agent ?? args.createdWithAgent if (startupTrustAgent) { await this.markRemoteWorkspaceTrustedForAgent( startupTrustAgent, repo.connectionId!, result.worktree.path ) } const terminal = await this.createTerminal(`path:${result.worktree.path}`, { command: sequencedStartup.command, ...(result.setup && args.startup ? { claudeAgentTeamsSourceCommand: args.startup.command } : {}), env: sequencedStartup.env, ...(sequencedStartup.launchConfig ? { launchConfig: sequencedStartup.launchConfig } : {}), ...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}), ...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry, ...ownerSurfacing(shouldActivate) }) if (args.startupDraftPaste) { this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste) } if (args.startupFollowup) { this.sendStartupFollowupWhenReady(terminal.handle, args.startupFollowup) } didSpawnStartup = true startupTerminalHandle = terminal.handle startupTerminalTabId = terminal.tabId ?? null startupTerminalPaneKey = terminal.paneKey ?? null startupTerminalPtyId = terminal.ptyId ?? null } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning ? `${warning} Also failed to create the startup terminal for ${result.worktree.path}: ${message}` : `Failed to create the startup terminal for ${result.worktree.path}: ${message}` } } if (shouldActivate) { const runtimeWillProvisionTerminals = didSpawnStartup && Boolean(result.setup || result.defaultTabs) if (runtimeWillProvisionTerminals) { // Why: remote/mobile task creates spawn the agent terminal in runtime, // so renderer activation may not materialize setup/default tabs. Await so // a failed setup spawn falls back to renderer activation for retry. const provisioned = await this.provisionManagedWorktreeTerminals({ worktreeSelector: `path:${result.worktree.path}`, worktreeId: result.worktree.id, worktreePath: result.worktree.path, ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // remote Setup tab runs the same script the sequenced agent waits on. ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) didSpawnSetup = provisioned.setupSpawned setupTerminalHandle = provisioned.setupTerminalHandle } // Why: omit setup from activation when runtime spawned it; on spawn // failure fall through with the wrapped command so renderer retries. const activationSetup = didSpawnSetup ? undefined : result.setup ? { ...result.setup, ...(didSpawnStartup && wrappedSetupCommandStr ? { command: wrappedSetupCommandStr } : {}) } : undefined const activationDefaultTabs = runtimeWillProvisionTerminals ? undefined : result.defaultTabs if (args.startup && !didSpawnStartup) { this.notifyActivateWorktree( repo.id, result.worktree.id, activationSetup, args.startup, activationDefaultTabs ) } else { this.notifyActivateWorktree( repo.id, result.worktree.id, activationSetup, undefined, activationDefaultTabs ) } } if ( !shouldActivate && this.ptyController?.spawn && (result.setup || result.defaultTabs || didSpawnStartup) ) { // Why: inactive terminal materialization matches normal worktree creation, // but setup/default tab failures must not gate automation dispatch. const provisioning = this.provisionManagedWorktreeTerminals({ worktreeSelector: `path:${result.worktree.path}`, worktreeId: result.worktree.id, worktreePath: result.worktree.path, ...(result.setup ? { setup: result.setup } : {}), ...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'), observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}), surfaceOwner: false }) // Why: runtime owns setup spawning here, so omit setup from the RPC result // to keep the headless/mobile caller from launching it a second time. if (args.awaitTerminalProvisioning) { const provisioned = await provisioning didSpawnSetup = provisioned.setupSpawned setupTerminalHandle = provisioned.setupTerminalHandle } else { void provisioning if (result.setup) { didSpawnSetup = true } } } else if (!shouldActivate && this.ptyController?.spawn) { try { await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false }) } catch (err) { const message = err instanceof Error ? err.message : String(err) warning = warning ? `${warning} Also failed to create the initial terminal for ${result.worktree.path}: ${message}` : `Failed to create the initial terminal for ${result.worktree.path}: ${message}` } } const returnedSetup = didSpawnSetup ? undefined : result.setup ? { ...result.setup, ...(didSpawnStartup && wrappedSetupCommandStr ? { command: wrappedSetupCommandStr } : {}) } : undefined const resultForRenderer = returnedSetup ? { ...result, setup: returnedSetup } : (() => { const { setup: _setup, ...resultWithoutSetup } = result return resultWithoutSetup })() const resultWithStartupTerminal = didSpawnStartup && startupTerminalHandle ? { ...resultForRenderer, startupTerminal: { spawned: true, handle: startupTerminalHandle, ...(startupTerminalTabId ? { tabId: startupTerminalTabId } : {}), ...(startupTerminalPaneKey ? { paneKey: startupTerminalPaneKey } : {}), ...(startupTerminalPtyId ? { ptyId: startupTerminalPtyId } : {}), surface: 'background' as const } } : resultForRenderer const requestedSetupDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') const setupReceipt = { requested: requestedSetupDecision, hookFound: Boolean(result.setup), startupPolicy: result.setup?.waitForAgentStartup ? ('wait-for-setup' as const) : ('start-immediately' as const), state: requestedSetupDecision === 'skip' ? ('skipped' as const) : !result.setup ? ('not_configured' as const) : didSpawnSetup ? ('running' as const) : ('spawn_failed' as const), ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) } const resultWithSetupReceipt = args.awaitTerminalProvisioning ? { ...resultWithStartupTerminal, setupReceipt } : resultWithStartupTerminal return warning ? { ...resultWithSetupReceipt, warning } : resultWithSetupReceipt } /** * Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight * serialization with all other callers. Never rejects — callers * log-and-proceed on offline failures (§3.3 Lifecycle). * * Why a shared cache on the runtime instead of module-scoped: §7.1 relies on * one cache for BOTH the renderer create path and `probeWorktreeDrift`. A * dispatch tick that reuses a just-completed create-path fetch is the * primary telemetry target; splitting the cache by call-site would double * the fetch load on warm repos. */ async getCanonicalFetchKey( repoPath: string, remote: string, gitOptions: { wslDistro?: string } = {} ): Promise { const runtimeKey = gitOptions.wslDistro ? `wsl:${gitOptions.wslDistro}` : 'local' const cacheKey = `${runtimeKey}::${repoPath}::${remote}` const cached = this.canonicalFetchKeyCache.get(cacheKey) if (cached !== undefined) { setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, cached, REMOTE_FETCH_CACHE_MAX) return cached } let resolved = cacheKey try { const { stdout } = await gitExecFileAsync( ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: repoPath, ...gitOptions } ) const commonDir = stdout.trim() if (commonDir) { resolved = `${runtimeKey}::${commonDir}::${remote}` } } catch { // Fall through to the caller-provided path. The fetch still runs from // repoPath; this key only controls cache sharing. } setBoundedMapEntry(this.canonicalFetchKeyCache, cacheKey, resolved, REMOTE_FETCH_CACHE_MAX) return resolved } private enqueueRemoteFetch( remoteKey: string, runFetch: () => Promise ): Promise { const previous = this.remoteFetchQueueTail.get(remoteKey) const promise = previous ? previous.then(runFetch, runFetch) : runFetch() this.remoteFetchQueueTail.set(remoteKey, promise) promise.finally(() => { if (this.remoteFetchQueueTail.get(remoteKey) === promise) { this.remoteFetchQueueTail.delete(remoteKey) } }) return promise } private getFreshFetchCompletedAt(key: string): number | null { const lastAt = this.fetchLastCompletedAt.get(key) if (lastAt === undefined) { return null } if (Date.now() - lastAt < FETCH_FRESHNESS_MS) { setBoundedMapEntry(this.fetchLastCompletedAt, key, lastAt, REMOTE_FETCH_CACHE_MAX) return lastAt } this.fetchLastCompletedAt.delete(key) return null } private rememberFreshFetchCompletedAt(key: string, completedAt = Date.now()): void { setBoundedMapEntry(this.fetchLastCompletedAt, key, completedAt, REMOTE_FETCH_CACHE_MAX) } async getOrStartRemoteFetch( repoPath: string, remote: string, gitOptions: { wslDistro?: string } = {} ): Promise { const key = await this.getCanonicalFetchKey(repoPath, remote, gitOptions) if (this.getFreshFetchCompletedAt(key) !== null) { // Why: freshness window hit — skip the fetch entirely. Do NOT reuse any // in-flight promise here; the timestamp is only written on success, so // hitting this branch means a previous fetch did succeed recently. return { ok: true } } const existing = this.fetchInflight.get(key) if (existing) { // Why: genuine serialization (not check-then-set). Two callers racing // on the same repo+remote share the single underlying `git fetch`. return existing } const promise = this.enqueueRemoteFetch(key, () => gitExecFileAsync(['fetch', remote], { cwd: repoPath, ...gitOptions, // Why: cap the create-path base-ref fetch so a stuck first-auth on // Windows (GCM prompt) fails fast instead of hanging creation (STA-1292). timeout: REMOTE_FETCH_TIMEOUT_MS }) .then((): RemoteFetchResult => { // Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection // would make the freshness cache lie about the last known remote state. this.rememberFreshFetchCompletedAt(key) return { ok: true } }) .catch((err): RemoteFetchResult => { // Why: swallow here so awaiters don't throw at the await site. Outer // create/dispatch paths are already tolerant of offline fetch failure; // this is the behavioral contract of this helper. console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err) return { ok: false, errorKind: 'git_error' } }) ).finally(() => { // Why (§3.3 Lifecycle): evict on BOTH success and rejection. A // rejected entry that survived in the Map would wedge every future // create on this repo until Orca restarted (the F2 bug §3.3 pins). this.fetchInflight.delete(key) }) this.fetchInflight.set(key, promise) return promise } async getOrStartRemoteTrackingBaseRefresh( repoPath: string, base: RemoteTrackingBase, gitOptions: { wslDistro?: string } = {} ): Promise { const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote, gitOptions) const key = await this.getCanonicalFetchKey( repoPath, `base:${base.remote}:${base.branch}`, gitOptions ) if (this.getFreshFetchCompletedAt(key) !== null) { // Why: exact-base freshness is the safety boundary. A full remote fetch // can be narrowed by repo refspecs, so it must not prove this branch. return { ok: true } } const existing = this.fetchInflight.get(key) if (existing) { return existing } const promise = this.enqueueRemoteFetch(remoteKey, async () => { if (this.getFreshFetchCompletedAt(key) !== null) { return { ok: true } } // Why: this exact refresh gates worktree create; ordinary fetches still own maintenance. return gitExecFileAsync( [ ...GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS, 'fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}` ], { cwd: repoPath, ...gitOptions, // Why: exact remote-base refresh is the network gate for worktree // creation, so honor repo SSH routing and bound custom wrappers. useConfiguredSshCommandForNetwork: true, timeout: REMOTE_FETCH_TIMEOUT_MS } ) .then((): RemoteFetchResult => { this.rememberFreshFetchCompletedAt(key) return { ok: true } }) .catch((err): RemoteFetchResult => { console.warn( `[refreshRemoteTrackingBase] ${base.base} refresh failed for ${repoPath}:`, err ) return { ok: false, errorKind: 'git_error' } }) }).finally(() => { this.fetchInflight.delete(key) }) this.fetchInflight.set(key, promise) return promise } async fetchRemoteWithCache( repoPath: string, remote: string, gitOptions: { wslDistro?: string } = {} ): Promise { await this.getOrStartRemoteFetch(repoPath, remote, gitOptions) } async resolveRemoteTrackingBase( repoPath: string, baseBranch: string, gitOptions: { wslDistro?: string } = {} ): Promise { let remotes: string[] try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath, ...gitOptions }) remotes = stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) } catch { return null } const remoteRefPrefix = 'refs/remotes/' const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix) ? baseBranch.slice(remoteRefPrefix.length) : baseBranch const remote = remotes .filter((candidate) => shortBaseBranch.startsWith(`${candidate}/`)) .sort((a, b) => b.length - a.length)[0] if (!remote) { return null } const branch = shortBaseBranch.slice(remote.length + 1) if (!branch) { return null } return { remote, branch, ref: `refs/remotes/${remote}/${branch}`, base: `${remote}/${branch}` } } async hasRemoteTrackingRef( repoPath: string, base: RemoteTrackingBase, gitOptions: { wslDistro?: string } = {} ): Promise { try { await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { cwd: repoPath, ...gitOptions }) return true } catch { return false } } recordOptimisticReconcileToken(worktreeId: string): string { const token = randomUUID() this.optimisticReconcileTokens.set(worktreeId, token) return token } clearOptimisticReconcileToken(worktreeId: string): void { this.optimisticReconcileTokens.delete(worktreeId) } emitWorktreeBaseStatus(event: WorktreeBaseStatusEvent): void { this.notifier?.worktreeBaseStatus?.(event) } async reconcileWorktreeBaseStatus(args: { repoId: string repoPath: string worktreeId: string base: RemoteTrackingBase branchName: string createdBaseSha: string token: string fetchPromise: Promise }): Promise { const stillCurrent = (): boolean => this.optimisticReconcileTokens.get(args.worktreeId) === args.token const emit = (event: Omit): void => { if (!stillCurrent()) { return } this.notifier?.worktreeBaseStatus?.({ repoId: args.repoId, worktreeId: args.worktreeId, base: args.base.base, remote: args.base.remote, ...event }) } const resolvePublishRemote = async (): Promise => { // Why: repos whose canonical publish remote is named differently (e.g. // `upstream`, a forked `myfork`, or any non-`origin` configuration — // including multi-segment names like `foo/bar` that this PR's resolver // explicitly supports) would otherwise silently skip the conflict // signal. Resolve from git config in priority order: // 1) branch..pushRemote (explicit per-branch override) // 2) remote.pushDefault (workspace-wide override) // 3) branch..remote (tracked remote) // 4) the base ref's own remote (matches resolveRemoteTrackingBase) // 5) `origin` as a final fallback. const tryConfig = async (key: string): Promise => { try { const { stdout } = await gitExecFileAsync(['config', '--get', key], { cwd: args.repoPath }) const value = stdout.trim() return value || null } catch { return null } } return ( (await tryConfig(`branch.${args.branchName}.pushRemote`)) ?? (await tryConfig('remote.pushDefault')) ?? (await tryConfig(`branch.${args.branchName}.remote`)) ?? args.base.remote ?? 'origin' ) } const checkPublishRemoteConflict = async (): Promise => { const publishRemote = await resolvePublishRemote() try { if (publishRemote !== args.base.remote) { const result = await this.getOrStartRemoteFetch(args.repoPath, publishRemote) if (!result.ok) { return } } await gitExecFileAsync( ['rev-parse', '--verify', `refs/remotes/${publishRemote}/${args.branchName}^{commit}`], { cwd: args.repoPath } ) if (stillCurrent()) { this.notifier?.worktreeRemoteBranchConflict?.({ repoId: args.repoId, worktreeId: args.worktreeId, remote: publishRemote, branchName: args.branchName }) } } catch { // No publish-remote conflict is the common case; stay quiet. } } try { const fetchResult = await args.fetchPromise if (!stillCurrent()) { return } if (!fetchResult.ok) { emit({ status: 'unknown' }) return } const { stdout } = await gitExecFileAsync( ['rev-parse', '--verify', `${args.base.ref}^{commit}`], { cwd: args.repoPath } ) const postFetchSha = stdout.trim() if (postFetchSha === args.createdBaseSha) { emit({ status: 'current' }) await checkPublishRemoteConflict() return } try { await gitExecFileAsync(['merge-base', '--is-ancestor', args.createdBaseSha, postFetchSha], { cwd: args.repoPath }) } catch { emit({ status: 'base_changed' }) await checkPublishRemoteConflict() return } const { stdout: countStdout } = await gitExecFileAsync( ['rev-list', '--count', `${args.createdBaseSha}..${postFetchSha}`], { cwd: args.repoPath } ) const behind = Number(countStdout.trim()) if (!Number.isFinite(behind) || behind <= 0) { emit({ status: 'current' }) await checkPublishRemoteConflict() return } const { stdout: logStdout } = await gitExecFileAsync( ['log', '--format=%s', '-n', '5', `${args.createdBaseSha}..${postFetchSha}`], { cwd: args.repoPath } ) emit({ status: 'drift', behind, recentSubjects: logStdout.split('\n').filter((line) => line.trim().length > 0) }) await checkPublishRemoteConflict() } catch (err) { console.warn(`[worktree-base-status] reconcile failed for ${args.worktreeId}:`, err) emit({ status: 'unknown' }) } finally { // Why: reconcile is one-shot; clear the token so long-lived sessions // that create many worktrees without removing them don't grow the // optimisticReconcileTokens map monotonically. Removal still no-ops // because the entry is already gone. if (this.optimisticReconcileTokens.get(args.worktreeId) === args.token) { this.optimisticReconcileTokens.delete(args.worktreeId) } } } /** * Probe how far the worktree's HEAD is behind its tracking remote. Returns * null when the probe cannot establish a signal (no default base ref, or * git failure). Dispatch treats null as "unknown — proceed" (§3.1); only * knowing-and-stale refuses. */ async probeWorktreeDrift(worktreeSelector: string): Promise<{ base: string behind: number recentSubjects: string[] } | null> { const wt = await this.resolveWorktreeSelector(worktreeSelector) if (!this.store) { return null } const repo = this.store.getRepos().find((r) => r.id === wt.repoId) if (!repo) { return null } if (repo.connectionId) { // Why: the drift probe uses local git helpers. Until the SSH provider // exposes equivalent remote refs/log plumbing, fail closed to "unknown" // instead of probing a server path on the desktop filesystem. return null } const localGitExecOptions = getLocalProjectGitExecOptions(this.requireStore(), repo) const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const meta = this.store.getWorktreeMeta(wt.id) const base = meta?.baseRef || meta?.sparseBaseRef || repo.worktreeBaseRef || (await getBaseRefDefault(repo.path, localWorktreeGitOptions)) if (!base) { // Why: brand-new repo with no remote primary — nothing to compare // against, so there's no meaningful drift to report. Dispatch should // not block on a probe that cannot form an opinion. return null } const remoteTrackingBase = await this.resolveRemoteTrackingBase( repo.path, base, localWorktreeGitOptions ) if (!remoteTrackingBase) { return null } const remote = remoteTrackingBase.remote // Why: fetch failures are non-fatal; we proceed with whatever the // last-known remote ref points at. `fetchRemoteWithCache` never throws. await this.fetchRemoteWithCache(repo.path, remote, localWorktreeGitOptions) const drift = getRemoteDrift(wt.path, 'HEAD', base, localGitExecOptions) if (!drift) { return null } const recentSubjects = getRecentDriftSubjects( wt.path, 'HEAD', base, DRIFT_PROBE_SUBJECT_LIMIT, localGitExecOptions ) return { base, behind: drift.behind, recentSubjects } } async updateManagedWorktreeMeta( worktreeSelector: string, updates: Omit, 'pushTarget'> & { pushTarget?: GitPushTarget | null lineage?: { parentWorktree?: string noParent?: boolean } } ) { if (!this.store) { throw new Error('runtime_unavailable') } const worktree = await this.resolveWorktreeSelector(worktreeSelector) const { lineage, ...metaUpdates } = updates if (lineage?.parentWorktree) { this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(worktree.repoId) } const shouldClearPushTarget = Object.prototype.hasOwnProperty.call(metaUpdates, 'pushTarget') && metaUpdates.pushTarget === null const normalizedMetaUpdates: Partial = shouldClearPushTarget ? { ...metaUpdates, pushTarget: undefined } : (metaUpdates as Partial) const persistedMetaUpdates: Partial = omitUndefinedProperties( normalizedMetaUpdates.displayName !== undefined ? { ...normalizedMetaUpdates, pendingFirstAgentMessageRename: false, firstAgentMessageRenameError: null } : normalizedMetaUpdates ) if (shouldClearPushTarget) { // Why: omitUndefinedProperties protects ordinary optional RPC fields, but // pushTarget:null is an explicit request to remove persisted target metadata. persistedMetaUpdates.pushTarget = undefined } if (lineage?.noParent === true) { this.store.removeWorktreeLineage?.(worktree.id) this.store.removeWorkspaceLineage?.(worktreeWorkspaceKey(worktree.id)) } else if (lineage?.parentWorktree) { const parent = await this.resolveWorktreeSelector(lineage.parentWorktree) this.validateLineageParent(worktree, parent) if (!worktree.instanceId || !parent.instanceId) { throw new RuntimeLineageError( 'LINEAGE_PARENT_CONTEXT_MISSING', 'Worktree instance identity was unavailable.' ) } if (!this.store.setWorktreeLineage) { throw new RuntimeLineageError( 'LINEAGE_PARENT_CONTEXT_MISSING', 'Worktree lineage storage was unavailable.' ) } const createdAt = Date.now() this.store.setWorktreeLineage(worktree.id, { worktreeId: worktree.id, worktreeInstanceId: worktree.instanceId, parentWorktreeId: parent.id, parentWorktreeInstanceId: parent.instanceId, origin: 'manual', capture: { source: 'manual-action', confidence: 'explicit' }, createdAt }) this.store.setWorkspaceLineage?.({ childWorkspaceKey: worktreeWorkspaceKey(worktree.id), childInstanceId: worktree.instanceId, parentWorkspaceKey: worktreeWorkspaceKey(parent.id), parentInstanceId: parent.instanceId, origin: 'manual', capture: { source: 'manual-action', confidence: 'explicit' }, createdAt }) } this.store.setWorktreeMeta(worktree.id, stripOrcaProvenanceMetaUpdates(persistedMetaUpdates)) // Why: unlike renderer-initiated optimistic updates, CLI callers need an // explicit push so the editor refreshes metadata changed outside the UI. this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(worktree.repoId) return await this.showManagedWorktree(`id:${worktree.id}`) } persistManagedWorktreeSortOrder(orderedIds: string[]): { updated: number } { if (!this.store) { throw new Error('runtime_unavailable') } const now = Date.now() let updated = 0 for (let i = 0; i < orderedIds.length; i++) { // Why: a sort-order snapshot must only reorder existing worktrees, never // mint new meta — a stale id would otherwise resurrect an orphan workspace // (setWorktreeMeta has no repo-existence check). if (!this.store.getWorktreeMeta(orderedIds[i])) { continue } this.store.setWorktreeMeta(orderedIds[i], { sortOrder: now - i * 1000 }) updated++ } this.invalidateResolvedWorktreeCache() this.notifyReposChanged() return { updated } } async resolveManagedPrBase(args: { repoSelector: string prNumber: number headRefName?: string baseRefName?: string isCrossRepository?: boolean }): Promise { if (!this.store) { throw new Error('runtime_unavailable') } let repo: Repo try { repo = await this.resolveRepoSelector(args.repoSelector) } catch { return { error: 'Repo not found' } } if (isFolderRepo(repo)) { return { error: 'Folder mode does not support creating worktrees.' } } const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null const localGitExecOptions = sshGitProvider ? undefined : getLocalProjectGitExecOptions(this.requireStore(), repo) const localWorktreeGitOptions = sshGitProvider ? {} : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) // Why: one resolver keeps source preference and hosting identity aligned // across local, WSL, and SSH worktree creation. const resolveRemote = (): Promise => resolveGitHubReviewHeadRemote({ repoPath: repo.path, issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec }) // Why: SSH review-head fetches require narrow write-capable RPCs. const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => fetchPrHeadTrackingRef( repo, sshGitProvider, remote, branch, localGitExecOptions ? { localGitExecOptions } : {} ) const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => fetchGitHubPullRequestHeadRef( repo, sshGitProvider, remote, prNumber, localGitExecOptions ? { localGitExecOptions } : {} ) return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, headRefName: args.headRefName, baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef, resolveRemote }) } async resolveManagedMrBase(args: { repoSelector: string mrIid: number sourceBranch?: string targetBranch?: string isCrossRepository?: boolean }): Promise< { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } | { error: string } > { if (!this.store) { throw new Error('runtime_unavailable') } let repo: Repo try { repo = await this.resolveRepoSelector(args.repoSelector) } catch { return { error: 'Repo not found' } } if (isFolderRepo(repo)) { return { error: 'Folder mode does not support creating worktrees.' } } const sshGitProvider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null const localGitExecOptions = sshGitProvider ? undefined : getLocalProjectGitExecOptions(this.requireStore(), repo) const localWorktreeGitOptions = sshGitProvider ? {} : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) let sourceBranch = args.sourceBranch?.trim() ?? '' let targetBranch = args.targetBranch?.trim() ?? '' let isCrossRepository = args.isCrossRepository === true if (!sourceBranch) { let remote: string try { remote = await this.resolveGitLabIssueSourceRemote( repo.path, repo.issueSourcePreference, repo.connectionId ?? null, localWorktreeGitOptions ) } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } } const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions) const projectRef = await getGitLabProjectRefForRemote( repo.path, remote, knownHosts, repo.connectionId ?? null, localWorktreeGitOptions ) if (!projectRef) { return { error: 'No GitLab project found for this repository.' } } const item = await getGitLabWorkItemByProjectRef( repo.path, projectRef, args.mrIid, 'mr', repo.connectionId ?? null, localWorktreeGitOptions ) if (!item || item.type !== 'mr') { return { error: `MR !${args.mrIid} not found.` } } sourceBranch = (item.branchName ?? '').trim() targetBranch = (item.baseRefName ?? '').trim() if (!sourceBranch) { return { error: `MR !${args.mrIid} has no source branch.` } } if (item.isCrossRepository === true) { isCrossRepository = true } } let remote: string try { remote = await this.resolveGitLabIssueSourceRemote( repo.path, repo.issueSourcePreference, repo.connectionId ?? null, localWorktreeGitOptions ) } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } } const compareBaseRef = targetBranch ? `refs/remotes/${remote}/${targetBranch}` : undefined const fetchRemoteTrackingRef = async (branch: string, ref: string): Promise => { await (sshGitProvider ? sshGitProvider.fetchRemoteTrackingRef(repo.path, remote, branch, ref) : gitExec(['fetch', remote, `+refs/heads/${branch}:${ref}`])) } // Why: the target/compare branch is optional (it only powers the diff // base). A merged MR may have had its target ref deleted, so a fetch // failure must NOT abort the whole resolution — that would discard the // already-verified source-branch base and silently fall back to the repo // default branch. Degrade gracefully by dropping compareBaseRef instead. const fetchCompareBaseRef = (): Promise => fetchCompareBaseRefWithLocalFallback({ compareBaseRef, fetchCompareBaseRef: (ref) => fetchRemoteTrackingRef(targetBranch, ref), gitExec, logLabel: '[runtime:resolveManagedMrBase]', logContext: { remote, targetBranch, mrIid: args.mrIid } }) if (isCrossRepository) { const mrRef = `refs/merge-requests/${args.mrIid}/head` // Why: soft-keep needs identity when the fetch throws before returning a path. // Success uses the path returned by the fetch itself (writer-authoritative). let softKeepLocalRefPromise: Promise | undefined const resolveSoftKeepLocalRef = (): Promise => { softKeepLocalRefPromise ??= (async () => { try { const { stdout } = await gitExec(['remote', 'get-url', remote]) const remoteUrl = stdout.trim() if (!remoteUrl) { return null } return gitlabMergeRequestHeadLocalRef( reviewHeadRemoteRefComponent(remote, remoteUrl), args.mrIid ) } catch { return null } })() return softKeepLocalRefPromise } const resolveDurableHeadSha = async (localRef: string | null): Promise => { if (!localRef) { return null } try { const { stdout } = await gitExec(['rev-parse', '--verify', `${localRef}^{commit}`]) return stdout.trim() || null } catch { return null } } try { const localRef = await fetchGitLabMergeRequestHeadRef( repo, sshGitProvider, remote, args.mrIid, localGitExecOptions ? { localGitExecOptions } : {} ) const sha = await resolveDurableHeadSha(localRef) if (!sha) { return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } } const compareBaseFetched = await fetchCompareBaseRef() return { baseBranch: sha, ...(compareBaseFetched ? { compareBaseRef } : {}) } } catch (error) { const message = error instanceof Error ? error.message : String(error) // Why: mirror compare-base — a transient transport failure must not fail // the resolve when a prior fetch already pinned the durable head ref. A // missing remote ref (deleted MR/fork), auth failure, or stale-relay // error must fail hard: serving the durable ref there would check out a // dead or unauthorized tip and mask the actionable error. if (isTransientReviewHeadFetchError(error)) { const localSha = await resolveDurableHeadSha(await resolveSoftKeepLocalRef()) if (localSha) { console.warn( '[runtime:resolveManagedMrBase] MR head fetch failed; using durable local ref', { remote, mrIid: args.mrIid, error: message.split('\n')[0] } ) const compareBaseFetched = await fetchCompareBaseRef() return { baseBranch: localSha, ...(compareBaseFetched ? { compareBaseRef } : {}) } } } return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } } } try { await fetchRemoteTrackingRef(sourceBranch, `refs/remotes/${remote}/${sourceBranch}`) } catch (error) { const message = error instanceof Error ? error.message : String(error) return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` } } const remoteRef = `${remote}/${sourceBranch}` try { await gitExec(['rev-parse', '--verify', remoteRef]) } catch { return { error: `Remote ref ${remoteRef} does not exist after fetch.` } } const compareBaseFetched = await fetchCompareBaseRef() return { baseBranch: remoteRef, ...(compareBaseFetched ? { compareBaseRef } : {}), pushTarget: { remoteName: remote, branchName: sourceBranch } } } private async resolveGitLabIssueSourceRemote( repoPath: string, preference?: Repo['issueSourcePreference'], connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} ): Promise { const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const localGitOptionArgs = Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] if (preference === 'origin') { const origin = await getGitLabProjectRefForRemote( repoPath, 'origin', knownHosts, connectionId, ...localGitOptionArgs ) if (origin) { return 'origin' } throw new Error('No GitLab project found for origin.') } if (preference === 'upstream') { const upstream = await getGitLabProjectRefForRemote( repoPath, 'upstream', knownHosts, connectionId, ...localGitOptionArgs ) if (upstream) { return 'upstream' } const origin = await getGitLabProjectRefForRemote( repoPath, 'origin', knownHosts, connectionId, ...localGitOptionArgs ) if (origin) { return 'origin' } throw new Error('No GitLab project found for upstream or origin.') } const upstream = await getGitLabProjectRefForRemote( repoPath, 'upstream', knownHosts, connectionId, ...localGitOptionArgs ) if (upstream) { return 'upstream' } const origin = await getGitLabProjectRefForRemote( repoPath, 'origin', knownHosts, connectionId, ...localGitOptionArgs ) if (origin) { return 'origin' } if (connectionId) { const provider = requireSshGitProvider(connectionId) const { stdout } = await provider.exec(['remote'], repoPath) return pickPreferredGitRemote(stdout.split('\n')) } return getDefaultRemote(repoPath, localGitOptions) } private async resolveWorktreeRemovalTarget( worktreeSelector: string ): Promise { try { const worktree = await this.resolveWorktreeSelector(worktreeSelector) const removalTarget = { id: worktree.id, repoId: worktree.repoId, path: worktree.path } return worktree.pushTarget ? { ...removalTarget, pushTarget: worktree.pushTarget } : removalTarget } catch (error) { if (!(error instanceof Error) || error.message !== 'selector_not_found') { throw error } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) const meta = removalTarget ? this.store?.getWorktreeMeta(removalTarget.id) : undefined if (!removalTarget || !meta) { throw error } // Why: delete requests can arrive after Git no longer lists the worktree. // Only exact IDs with persisted Orca metadata are accepted here so // branch/path selectors cannot resolve to an arbitrary missing path. return meta.pushTarget ? { ...removalTarget, pushTarget: meta.pushTarget } : removalTarget } } private removeWorktreeMetadataAndHistory(store: RuntimeStore, worktreeId: string): void { // Why: worktree IDs are path-derived and can be recreated, so removal must // purge history and process-local caches before the ID points at new state. const hostId = store.getWorktreeMeta(worktreeId)?.hostId if (hostId) { store.removeWorktreeMeta(worktreeId, hostId) } else { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) this.closeHeadlessBrowserPagesForWorktree(worktreeId) } // Why: headless offscreen browser pages are main-process BrowserWindows that // outlive a worktree unless explicitly closed — removing a worktree without // closing its open panes leaks the windows for the life of the serve process. private closeHeadlessBrowserPagesForWorktree(worktreeId: string): void { if (!this.offscreenBrowserBackend || !this.agentBrowserBridge?.tabList) { return } for (const tab of this.agentBrowserBridge.tabList(worktreeId).tabs) { void this.offscreenBrowserBackend.closeTab(tab.browserPageId).catch(() => {}) } } private rememberPreservedBranchCleanupTarget( worktreeId: string, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined ): void { if (result?.preservedBranch) { const head = result.preservedBranch.head ?? fallbackHead if (!head) { throw new Error( `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } this.preservedBranchCleanupByWorktreeId.set(worktreeId, { branchName: result.preservedBranch.branchName, head, ...(pushTarget ? { pushTarget } : {}) }) return } this.preservedBranchCleanupByWorktreeId.delete(worktreeId) } private preserveBranchHeadFallback( result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined ): RemoveWorktreeResult { if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { return result ?? {} } return { ...result, preservedBranch: { ...result.preservedBranch, head: fallbackHead } } } async forceDeletePreservedBranch( worktreeSelector: string, branchName: string, expectedHead: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) const cleanupTarget = removalTarget ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) : undefined if ( !removalTarget || !cleanupTarget || cleanupTarget.branchName !== branchName || cleanupTarget.head !== expectedHead ) { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } const repo = this.store.getRepo(removalTarget.repoId) if (!repo) { throw new Error('repo_not_found') } if (isFolderRepo(repo)) { throw new Error('Folder workspaces do not have local Git branches.') } if (repo.connectionId) { const provider = requireSshGitProvider(repo.connectionId) // Why: SSH must use the write-capable relay RPC; the shared exec-based // helper routes through the read-only git.exec allowlist, which rejects // the worktree/update-ref/config writes this delete needs. await provider.forceDeletePreservedBranch( repo.path, cleanupTarget.branchName, cleanupTarget.head ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider, repo.path, removalTarget.id, cleanupTarget.pushTarget, this.store ) } else { const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(this.requireStore(), repo) await (Object.keys(localWorktreeGitOptions).length > 0 ? forceDeleteLocalBranch( repo.path, cleanupTarget.branchName, cleanupTarget.head, (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) ) : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, cleanupTarget.pushTarget, this.store, localWorktreeGitOptions ) } this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) return { deleted: true } } async removeManagedWorktree( worktreeSelector: string, force = false, runHooks = false, // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` // alone is already set by the ordinary delete confirmation. allowUnverifiedPtyStop = false ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const store = this.store const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) if (inFlightRemoval) { if (inFlightRemoval.optionsKey === optionsKey) { return inFlightRemoval.promise } throw new Error(`Worktree deletion already in progress: ${removalTarget.id}`) } // Why: runtime callers can race the same workspace through CLI/mobile // retries. Share one destructive Git/filesystem operation per worktree ID. const removal = (async (): Promise => { // Why: CLI, mobile and headless serve delete through here rather than the IPC handler; without // this span their freezes are as invisible as desktop deletes were before `worktree.remove`. return withWorktreeSpan({ stage: 'remove', path: removalTarget.path }, async () => { const repo = store.getRepo(removalTarget.repoId) if (!repo) { const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId) const sshPtyProvider = orphanHost?.kind === 'ssh' ? this.getSshProviderFn?.(orphanHost.targetId) : undefined const ptyProvider = sshPtyProvider ?? this.getLocalProvider() const externalOrphanHost = orphanHost?.kind === 'ssh' || orphanHost?.kind === 'runtime' if (ptyProvider) { // External host inventories must never sweep a same-id local workspace. await killAllProcessesForWorktree(removalTarget.id, { runtime: this, resolvedWorktreeId: removalTarget.id, ...(orphanHost?.kind === 'ssh' ? { resolvedConnectionId: orphanHost.targetId } : {}), ...(orphanHost?.kind === 'runtime' ? { resolvedRuntimeEnvironmentId: orphanHost.environmentId } : {}), localProvider: ptyProvider, onPtyStopped: this.onPtyStopped ?? undefined, ...(externalOrphanHost ? { includeProviderInventory: orphanHost?.kind === 'ssh' && Boolean(sshPtyProvider), includeLocalRegistry: false } : {}) }).catch((error) => { console.warn( `[worktree-teardown] orphan cleanup failed for ${removalTarget.id}:`, error ) }) } // Why: nothing is deleted on disk here, so watchers must be restored — a folder // workspace or explorer pane rooted at the same path stays live. const orphanFullPath = splitWorktreeId(removalTarget.id)?.worktreePath const orphanWatcherPath = splitWorktreeIdForFilesystem(removalTarget.id)?.worktreePath === orphanFullPath ? orphanFullPath : undefined if (orphanWatcherPath) { await this.acquireFileWatcherRemoval( orphanWatcherPath, orphanHost?.kind === 'ssh' ? orphanHost.targetId : undefined ) .then((gate) => gate.finish(false)) .catch(() => {}) } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(removalTarget.repoId) // Why: non-desktop callers must be able to tell "forgotten" from "deleted"; nothing left the disk. return { warning: `Project ${removalTarget.repoId} is no longer tracked, so ${removalTarget.path} was forgotten without deleting the directory or its Git worktree registration.` } } if (isFolderRepo(repo)) { if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { throw new Error( 'Cannot delete the project root workspace. Remove the folder project instead.' ) } // This service runs inside the selected runtime, so runtime-stamped repos use its // local PTY namespace; only a direct SSH connection is external from here. const folderConnectionId = repo.connectionId?.trim() || null const folderSshPtyProvider = folderConnectionId ? this.getSshProviderFn?.(folderConnectionId) : undefined const folderPtyProvider = folderSshPtyProvider ?? this.getLocalProvider() if (folderPtyProvider) { // Why: folder workspace deletion has no Git removal phase where PTYs // would otherwise be swept; tear them down before hiding the workspace. await killAllProcessesForWorktree(removalTarget.id, { runtime: this, resolvedWorktreeId: removalTarget.id, ...(folderConnectionId ? { resolvedConnectionId: folderConnectionId } : {}), localProvider: folderPtyProvider, onPtyStopped: this.onPtyStopped ?? undefined, ...(folderConnectionId ? { includeProviderInventory: Boolean(folderSshPtyProvider), includeLocalRegistry: false } : {}) }).catch((err) => { console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err) }) } this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) return {} } const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null const localWorktreeGitOptions = repo.connectionId ? {} : getLocalProjectWorktreeGitOptions(this.requireStore(), repo) const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 const registeredWorktrees = repo.connectionId ? await provider!.listWorktrees(repo.path) : hasLocalWorktreeGitOptions ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) : await listWorktreesStrict(repo.path) const removedMeta = store.getWorktreeMeta(removalTarget.id) const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget const registeredWorktree = findRegisteredDeletableWorktree( repo.path, removalTarget.path, registeredWorktrees ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false if ( canCleanupUnregisteredOrcaWorktreeDirectory({ meta: removedMeta }) ) { if (repo.connectionId) { if (!fsProvider) { throw new Error('SSH filesystem provider unavailable') } if (!fsProvider.lstat) { throw new Error('SSH filesystem provider lstat unavailable') } canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( removalTarget.path, repo.path, (path) => fsProvider.lstat!(path), (path) => fsProvider.readFile(path) ) } else { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) canCleanOrphanedDirectory = !isDangerousWorktreeRemovalPath(removalTarget.path, repo.path) && (await canSafelyRemoveOrphanedWorktreeDirectory( toLocalWorktreeRuntimePath(removalTarget.path, localWorktreeGitOptions), toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), access.statPath, access.readPath )) } } if (canCleanOrphanedDirectory) { assertWorktreeDoesNotContainRegisteredWorktree(removalTarget.path, registeredWorktrees) if (!force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } if (repo.connectionId) { const removalGate = await this.acquireFileWatcherRemoval( removalTarget.path, repo.connectionId ) let removalCompleted = false try { await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { connectionId: repo.connectionId, allowUnverifiedStop: allowUnverifiedPtyStop }) await fsProvider!.deletePath(removalTarget.path, true) removalCompleted = true } finally { await removalGate.finish(removalCompleted) } await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, removalTarget.id, removedPushTarget, store ) } else { const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) let removalCompleted = false try { await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { allowUnverifiedStop: allowUnverifiedPtyStop }) await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) removalCompleted = true } finally { await removalGate.finish(removalCompleted) } await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, store, localWorktreeGitOptions ) } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) return {} } if (!repo.connectionId) { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) const runtimeWorktreePath = toLocalWorktreeRuntimePath( removalTarget.path, localWorktreeGitOptions ) if ( await canCleanupUnregisteredOrcaLeftoverDirectory({ meta: removedMeta, worktreePath: removalTarget.path, runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) }) ) { if (!force) { throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) } const removalGate = await this.acquireFileWatcherRemoval(removalTarget.path) let removalCompleted = false try { await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { allowUnverifiedStop: allowUnverifiedPtyStop }) await removeLocalWorktreePath(removalTarget.path, localWorktreeGitOptions) removalCompleted = true } finally { await removalGate.finish(removalCompleted) } await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, store, localWorktreeGitOptions ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) return {} } } if ( await isRuntimeWorktreePathMissing(repo, removalTarget.path, localWorktreeGitOptions) ) { if (!force && !removedMeta) { // Why: without persisted metadata, require the renderer recovery // path before deleting Orca-only state for an unregistered path. throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) } // Why: a manually deleted worktree is already gone from Git and disk. // Finish runtime metadata cleanup without requiring force or touching // any unregistered path that still exists. await (repo.connectionId ? cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, removalTarget.id, removedPushTarget, store ) : cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, store, localWorktreeGitOptions )) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) return {} } throw new Error(`Refusing to delete unregistered worktree path: ${removalTarget.path}`) } const canonicalWorktreePath = registeredWorktree.path const deleteBranch = removedMeta?.preserveBranchOnDelete !== true // Why: a Git lock must block before archive hooks or linked-path cleanup // mutate the workspace; dirty-file force is a separate permission. try { assertWorktreeUnlockedForRemoval(registeredWorktree) } catch (error) { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } // Why: a prior forced Windows recovery can delete the directory but leave // Git's stale registration; recover and verify it before clearing metadata. if ( !repo.connectionId && force === true && process.platform === 'win32' && (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && removedMeta && (await isRuntimeWorktreePathMissing(repo, canonicalWorktreePath, localWorktreeGitOptions)) ) { const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ canonicalWorktreePath, repoPath: repo.path, localWorktreeGitOptions, registeredWorktree, deleteBranch }) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, store, localWorktreeGitOptions ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, removalResult, registeredWorktree.head, removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) return removalResult ?? {} } if (repo.connectionId) { const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} const removalGate = await this.acquireFileWatcherRemoval( canonicalWorktreePath, repo.connectionId ) let rawRemovalResult: RemoveWorktreeResult | undefined let removalCompleted = false try { await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { connectionId: repo.connectionId, allowUnverifiedStop: allowUnverifiedPtyStop }) rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0 ? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions) : provider!.removeWorktree(canonicalWorktreePath, force)) removalCompleted = true } finally { await removalGate.finish(removalCompleted) } const removalResult = this.preserveBranchHeadFallback( rawRemovalResult, registeredWorktree.head ) await cleanupUnusedWorktreePushTargetRemoteSsh( provider!, repo.path, removalTarget.id, removedPushTarget, store ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, removalResult, registeredWorktree.head, removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) return removalResult ?? {} } const hooks = getEffectiveHooks(repo) let warning: string | undefined if (hooks?.scripts.archive && runHooks) { const result = await runHook( 'archive', canonicalWorktreePath, repo, undefined, hasLocalWorktreeGitOptions ? localWorktreeGitOptions : undefined ) if (!result.success) { console.error( `[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output ) } } else if (hooks?.scripts.archive) { // Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in. warning = `orca.yaml archive hook skipped for ${canonicalWorktreePath}; pass --run-hooks to run it.` console.warn(`[hooks] ${warning}`) } const refreshedWorktrees = hasLocalWorktreeGitOptions ? await listWorktreesStrict(repo.path, localWorktreeGitOptions) : await listWorktreesStrict(repo.path) const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( repo.path, canonicalWorktreePath, refreshedWorktrees ) if (!refreshedRegisteredWorktree) { throw new Error( `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` ) } try { // Why: an archive hook can race another Git client that locks the row; // recheck before linked-path, watcher, or terminal teardown side effects. assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) } catch (error) { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } // Why: `orca.yaml` shared directories are symlinked in too, and a // directory-only ignore rule leaves those links untracked, so removal must // tolerate and unlink them exactly like the per-user shared paths. const linkedPaths = getWorktreeSharedLinkPaths(repo) const ignoredLinkedPaths = force ? [] : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) try { await (hasLocalWorktreeGitOptions ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { ...localWorktreeGitOptions, ...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {}) }) : ignoredLinkedPaths.length > 0 ? assertWorktreeCleanForRemoval(canonicalWorktreePath, force, { ignoredUntrackedPaths: ignoredLinkedPaths }) : assertWorktreeCleanForRemoval(canonicalWorktreePath, force)) } catch (error) { if (!isOrphanCompatiblePreflightError(error)) { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } // Why: Git can still classify this as an orphan after preflight; // retain strict PTY teardown before any recursive fallback deletion. } let removalResult: RemoveWorktreeResult | undefined const removalGate = await this.acquireFileWatcherRemoval(canonicalWorktreePath) let removalCompleted = false try { // Why: linked-path deletion is destructive too; PTYs must release every // handle before Windows or WSL filesystem cleanup starts. await this.stopPtysForDestructiveWorktreeRemoval(removalTarget.id, { allowUnverifiedStop: allowUnverifiedPtyStop }) if (linkedPaths.length > 0) { await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) } try { const removeOptions = { ...(!deleteBranch ? { deleteBranch } : {}), // Why: removal already validated the Git row under the selected // project runtime; keep branch cleanup on that same canonical row. knownRemovedWorktree: refreshedRegisteredWorktree, ...localWorktreeGitOptions } removalResult = this.preserveBranchHeadFallback( await removeWorktree(repo.path, canonicalWorktreePath, force, removeOptions), refreshedRegisteredWorktree.head ) } catch (error) { // Why: Git for Windows can deregister a clean worktree before its // recursive filesystem deletion fails transiently. const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ error, force, canonicalWorktreePath, repoPath: repo.path, localWorktreeGitOptions, registeredWorktree: refreshedRegisteredWorktree, deleteBranch, closeWatcher: (worktreePath) => this.closeFileWatchersForRemoval(worktreePath) }) if (recoveredRemovalResult) { removalResult = recoveredRemovalResult removalCompleted = true } else if (isOrphanedWorktreeError(error)) { const access = getLocalWorktreePathAccess(localWorktreeGitOptions) if ( await canSafelyRemoveOrphanedWorktreeDirectory( toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), access.statPath, access.readPath ) ) { await this.closeFileWatchersForRemoval(canonicalWorktreePath) await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( () => {} ) } else { console.warn( `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` ) } // Why: `git worktree remove` failed, so git's internal worktree tracking // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out // remains locked — other worktrees cannot check it out. await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path, ...localWorktreeGitOptions }).catch(() => {}) await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, store, localWorktreeGitOptions ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) removalCompleted = true return { ...(warning ? { warning } : {}) } } else { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } } removalCompleted = true } finally { await removalGate.finish(removalCompleted) } await cleanupUnusedWorktreePushTargetRemote( repo.path, removalTarget.id, removedPushTarget, store, localWorktreeGitOptions ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, removalResult, refreshedRegisteredWorktree.head, removedPushTarget ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() this.notifyWorktreesChanged(repo.id) return { ...removalResult, ...(warning ? { warning } : {}) } }) })() this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal }) try { const result = await removal this.emitWorktreeLifecycle({ kind: 'removed', worktreeId: removalTarget.id, path: removalTarget.path }) return result } finally { if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) { this.removeManagedWorktreeInFlight.delete(removalTarget.id) } } } async renameTerminal(handle: string, title: string | null): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { pty.pty.title = title // Why: a manual rename must outrank later agent OSC title updates (which // win by timestamp), so stamp it as the freshest title. pty.pty.titleUpdatedAt = Date.now() this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId) // Why: without a renderer the rename only lived on the live pty and was // lost on restart. Persist customTitle so a headless rebuild keeps it. if (!this.notifier?.renameTerminal && pty.pty.tabId) { this.persistHeadlessTerminalTitle(pty.pty.worktreeId, pty.pty.tabId, title) } for (const leaf of this.leaves.values()) { if (leaf.ptyId === pty.pty.ptyId) { this.notifier?.renameTerminal(leaf.tabId, title) return { handle, tabId: leaf.tabId, title } } } return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) this.notifier?.renameTerminal(leaf.tabId, title) return { handle, tabId: leaf.tabId, title } } private async resolveAgentTerminalCreateOptions( workspace: TerminalWorkspaceLaunchScope, opts: TerminalCreateOptions ): Promise { // Why: raw shell commands like `codex exec` must remain user-authored shell. // Only unmanaged, repo-backed, bare agent launches get Settings defaults. const callerSuppliedLaunch = opts.env || opts.launchConfig || opts.launchAgent || opts.startupCommandDelivery || opts.claudeAgentTeamsSourceCommand const store = this.store if (opts.startupAgent) { // Why: falling through unresolved would spawn a bare shell that can only time // out waiting for an agent. A caller-supplied launch contradicts the agent: // `command` would be overwritten, `resumeProviderSession` would pair resume // identity with a fresh launch. if (callerSuppliedLaunch || opts.command || opts.resumeProviderSession) { throw new Error( `startupAgent ${opts.startupAgent} cannot combine with a caller-supplied launch.` ) } if (!store) { throw new Error('runtime_unavailable') } } else if (callerSuppliedLaunch || !store || !opts.command || !workspace.repo) { return opts } const settings = store.getSettings() const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) const queuedShell = resolveLocalWindowsAgentStartupShell({ platform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) if (opts.startupAgent && !isTuiAgentEnabled(opts.startupAgent, settings.disabledTuiAgents)) { throw new Error(`Agent ${opts.startupAgent} is disabled. Choose an enabled agent.`) } const agent = opts.startupAgent ?? resolveBareAgentLaunchCommand({ command: opts.command, settings, platform, isRemote }) if (!agent) { return opts } const startupPlan = buildAgentStartupPlan({ agent, prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), platform, shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare // shell that never reaches agent readiness. if (opts.startupAgent) { throw new Error(`Could not build launch command for ${opts.startupAgent}.`) } return opts } if (workspace.connectionId) { await this.markRemoteWorkspaceTrustedForAgent(agent, workspace.connectionId, workspace.path) } else { this.markLocalWorkspaceTrustedForAgent(agent, workspace.path) } return { ...opts, command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), launchConfig: startupPlan.launchConfig, launchAgent: agent, startupCommandDelivery: startupPlan.startupCommandDelivery } } private getAgentSessionExecutionNamespace( workspace: TerminalWorkspaceLaunchScope, agent: TuiAgent ): { machine: string; principal: string; container: string; providerRoot: string } | null { if (workspace.connectionId) { // Why: SSH target ids are not execution-namespace proof. Preserve the // legacy launch until an attested route can safely participate in claims. return null } const wsl = parseWslUncPath(workspace.path) const principal = typeof process.getuid === 'function' ? `uid:${process.getuid()}` : `user:${process.env.USERNAME ?? ''}` return { machine: wsl ? 'wsl-host' : `native:${process.platform}`, principal, container: wsl ? `wsl:${wsl.distro.toLocaleLowerCase('en-US')}` : 'native', // Why: merging account roots is conservative (it may conflict) and can // never permit two TUIs to own one provider session. providerRoot: `profile-default:${agent}` } } private async executionOwnerSupportsAgentSessionOperation( workspace: TerminalWorkspaceLaunchScope, operation: 'resume' | 'create', signal?: AbortSignal ): Promise { const provider = workspace.connectionId ? this.getSshProviderFn?.(workspace.connectionId) : this.getLocalProvider() if (!provider) { // An unavailable route is not proof of an old owner; preserve the structured failure. return true } const probe = operation === 'resume' ? provider.supportsAgentSessionClaims : provider.supportsAgentSessionCreateOperations if (!probe) { // Local in-process PTYs need no wire negotiation; unknown SSH providers are legacy. return workspace.connectionId === null } try { return (await probe.call(provider, { signal })) === true } catch { // Why: this read-only check has not launched anything, so the old route remains safe. return false } } private toAgentSessionOptions( preferences: AgentLaunchPreferences | undefined ): Record | undefined { if (!preferences) { return undefined } const options = { ...(preferences.model ? { model: preferences.model } : {}), ...(preferences.effort ? { effort: preferences.effort } : {}), ...(preferences.mode ? { mode: preferences.mode } : {}) } return Object.keys(options).length > 0 ? options : undefined } async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, _caller: RuntimeAgentSessionRpcCaller = {} ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. throw new Error('agent_session_resume_not_authorized') } if (!this.store) { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) if ( !namespace || !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) ) { // Why: the renderer still holds the exact old request and may retry it before any side effect. throw new Error('agent_session_legacy_required') } // Why: nested SSH paths belong to the execution owner, so compatibility selection must happen before local filesystem canonicalization. const identity = canonicalizeAgentSessionIdentity(request.agent, request.providerSession) const claim = this.agentSessionClaimSigner.createClaim({ namespace, identity, canonicalWorktreeId: workspace.id }) const settings = this.store.getSettings() if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before resuming.') } const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) const shell = resolveLocalWindowsAgentStartupShell({ platform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) const startup = buildAgentResumeStartupPlan({ agent: request.agent, providerSession: identity.providerSession, cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: request.agentArgs !== undefined ? request.agentArgs : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), platform, shell, isRemote }) if (!startup) { throw new Error('agent_session_identity_required') } if (workspace.connectionId) { await this.markRemoteWorkspaceTrustedForAgent( request.agent, workspace.connectionId, workspace.path ) } else { this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path) } if (_caller.signal?.aborted) { throw new Error('client_disconnected') } const terminal = await this.createTerminal(`id:${workspace.id}`, { command: startup.launchCommand, env: startup.env, launchConfig: startup.launchConfig, launchAgent: request.agent, presentation: request.presentation ?? 'background', tabId: request.placement?.tabId, leafId: request.placement?.leafId, persistHostSessionBinding: true, agentSessionClaim: claim, signal: _caller.signal }) return { terminal, disposition: terminal.agentSessionDisposition ?? 'created' } } async createAgentSession( request: RuntimeCreateAgentSessionRequest, caller: RuntimeAgentSessionRpcCaller = {} ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const now = Date.now() const operationTimestamp = parseAgentSessionOperationTimestamp(request.clientOperationId) if ( operationTimestamp === null || operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS ) { throw new Error('agent_session_operation_invalid') } const callerKey = caller.clientId?.trim() || `trusted-local:${caller.clientKind ?? 'runtime'}` const operationKey = `${callerKey}\0${request.clientOperationId}` const requestFingerprint = createHash('sha256') .update( JSON.stringify([ request.worktree, request.agent, request.prompt ?? null, request.promptDelivery ?? null, request.agentArgs ?? null, request.agentArgs === undefined ? 'host-default' : 'client-override', request.launchPreferences?.model ?? null, request.launchPreferences?.effort ?? null, request.launchPreferences?.mode ?? null, request.startupCwd ?? null, request.presentation ?? null, request.placement?.tabId ?? null, request.placement?.leafId ?? null, request.viewMode ?? null ]) ) .digest('base64url') const existing = this.agentSessionCreateOperations.get(operationKey) if (existing) { if (existing.fingerprint !== requestFingerprint) { throw new Error('agent_session_operation_conflict') } const replayed = await existing.promise return { ...replayed, disposition: 'replayed' } } if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { // Why: once a tombstone could have expired, an unseen replay must never // be reinterpreted as permission to start another fresh agent. throw new Error('agent_session_operation_expired') } let callerOperationCount = 0 const callerPrefix = `${callerKey}\0` for (const key of this.agentSessionCreateOperations.keys()) { if (key.startsWith(callerPrefix)) { callerOperationCount += 1 } } if ( callerOperationCount >= AGENT_SESSION_OPERATION_PER_CLIENT_LIMIT || this.agentSessionCreateOperations.size >= AGENT_SESSION_OPERATION_GLOBAL_LIMIT ) { // Why: tombstones cannot be evicted early without making an old replay // capable of spawning again; reject new IDs until retained entries age out. throw new Error('agent_session_operation_capacity') } let retainReplayFence = false const operation = (async (): Promise => { // Why: reserve the client operation before any async preflight so concurrent retries cannot // both observe an empty ledger and reach the execution owner independently. const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) if ( !(await this.executionOwnerSupportsAgentSessionOperation( workspace, 'create', caller.signal )) ) { // Why: the exact legacy launch remains client-owned until this pre-spawn check succeeds. throw new Error('agent_session_legacy_required') } const startupCwd = this.resolveWorkspaceTerminalStartupCwd(workspace, request.startupCwd) // Why: aliases and object property order are client syntax, not authority; // fingerprint the host-resolved fields in one fixed order. const resolvedFingerprint = createHash('sha256') .update( JSON.stringify([ workspace.id, request.agent, request.prompt ?? null, request.promptDelivery ?? null, request.agentArgs ?? null, request.agentArgs === undefined ? 'host-default' : 'client-override', request.launchPreferences?.model ?? null, request.launchPreferences?.effort ?? null, request.launchPreferences?.mode ?? null, startupCwd ?? null, request.presentation ?? null, request.placement?.tabId ?? null, request.placement?.leafId ?? null, request.viewMode ?? null ]) ) .digest('base64url') const settings = this.store!.getSettings() if (!isTuiAgentEnabled(request.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : Boolean(workspace.connectionId) const shell = resolveLocalWindowsAgentStartupShell({ platform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) const startupArgs = { agent: request.agent, cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: request.agentArgs !== undefined ? request.agentArgs : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), sessionOptions: this.toAgentSessionOptions(request.launchPreferences), platform, shell, isRemote } const startup = request.promptDelivery === 'draft' ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) : buildAgentStartupPlan({ ...startupArgs, prompt: request.prompt ?? '', allowEmptyPromptLaunch: true }) if (!startup) { throw new Error('agent_session_identity_required') } if (workspace.connectionId) { await this.markRemoteWorkspaceTrustedForAgent( request.agent, workspace.connectionId, workspace.path ) } else { this.markLocalWorkspaceTrustedForAgent(request.agent, workspace.path) } if (caller.signal?.aborted) { throw new Error('client_disconnected') } let terminal: RuntimeTerminalCreate const executionOperationId = createHash('sha256') .update(this.runtimeId) .update('\0') .update(operationKey) .update('\0') .update(resolvedFingerprint) .digest('base64url') const operationTabId = request.placement?.tabId ?? deterministicAgentSessionUuid(`${executionOperationId}:tab`) const operationLeafId = request.placement?.leafId ?? deterministicAgentSessionUuid(`${executionOperationId}:leaf`) const operationHandle = `term_${deterministicAgentSessionUuid(`${executionOperationId}:handle`)}` try { terminal = await this.createTerminal(`id:${workspace.id}`, { command: startup.launchCommand, env: startup.env, launchConfig: startup.launchConfig, launchAgent: request.agent, startupCommandDelivery: startup.startupCommandDelivery, cwd: startupCwd, presentation: request.presentation ?? 'background', tabId: operationTabId, leafId: operationLeafId, preAllocatedHandle: operationHandle, viewMode: request.viewMode, persistHostSessionBinding: true, agentSessionCreateOperationId: executionOperationId, signal: caller.signal, onPtySpawnCommitted: () => { retainReplayFence = true } }) } catch (error) { if (isAgentSessionOperationOutcomeUnknown(error)) { retainReplayFence = true } throw error } return { terminal, disposition: 'created' } })() this.agentSessionCreateOperations.set(operationKey, { fingerprint: requestFingerprint, promise: operation }) const expireOperation = (): void => { const expiresAt = Math.max(now, operationTimestamp) + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS const timer = setTimeout( () => { if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { this.agentSessionCreateOperations.delete(operationKey) } }, Math.max(1, expiresAt - Date.now()) ) timer.unref?.() } try { const result = await operation expireOperation() return result } catch (error) { if (retainReplayFence) { // Why: the first PTY may still be alive; replay the same failure until // expiry instead of interpreting a lost outcome as a fresh spawn grant. expireOperation() } else if (this.agentSessionCreateOperations.get(operationKey)?.promise === operation) { this.agentSessionCreateOperations.delete(operationKey) } throw error } } async createTerminal( worktreeSelector?: string, opts: TerminalCreateOptions = {} ): Promise { if (opts.startupAgent && worktreeSelector === undefined) { // Why: the launch is resolved against a workspace, so with no selector // startupAgent is silently dropped and the terminal is a bare shell. throw new Error(`startupAgent ${opts.startupAgent} requires a workspace selector.`) } const presentation = resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() // Why: pre-diff createTerminal fell back to the renderer's active worktree // when no selector was provided. The new background-spawn branch hard- // requires a resolvable selector, so route the no-selector case through // the renderer IPC path to preserve that behavior. const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && (Boolean(opts.agentSessionClaim) || (!requiresRendererFocus && opts.rendererBacked !== true) || // Why: `orca serve` exposes the local runtime without a renderer // window. Renderer-backed Codex terminals are preferred for the app, // but headless CLI users still need a usable terminal handle. (opts.rendererBacked === true && rendererWindow === null)) if (shouldCreateInBackground) { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) let ptySpawnCommitReported = false const reportPtySpawnCommitted = (): void => { if (ptySpawnCommitReported) { return } ptySpawnCommitReported = true launchOpts.onPtySpawnCommitted?.() } const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path let preAllocatedHandle = launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys // off `${tabId}:${leafId}` — without these vars set on the PTY, the // hook payload arrives with an empty paneKey and the renderer cannot // attribute the event. Use a stable UUID leaf because hooks reject the // legacy numeric pane keys after the pane-id migration. const hintedTabId = launchOpts.tabId?.trim() const canAdoptPaneIdentity = hintedTabId !== undefined && isValidHostTerminalTabId(hintedTabId) && launchOpts.leafId !== undefined && isTerminalLeafId(launchOpts.leafId) let tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID() let leafId = canAdoptPaneIdentity ? (launchOpts.leafId as string) : randomUUID() let paneKey = makePaneKey(tabId, leafId) const claimedStablePaneCreate = this.ptyController.claimStablePaneCreate?.({ worktreeId: workspace.id, connectionId: workspace.connectionId, tabId, leafId }) let stablePaneCreateReleased = false const releaseStablePaneCreate = (): void => { if (stablePaneCreateReleased) { return } stablePaneCreateReleased = true claimedStablePaneCreate?.() } try { if (launchOpts.signal?.aborted) { throw new Error('client_disconnected') } const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({ cols: 120, rows: 40, cwd, connectionId: workspace.connectionId, worktreeId: workspace.id, preAllocatedHandle, tabId, leafId }) const launchToken = launchOpts.launchConfig ? (launchOpts.launchToken ?? randomUUID()) : undefined const baseEnv = { ...launchOpts.env, ...(launchToken ? { ORCA_AGENT_LAUNCH_TOKEN: launchToken } : {}) } const claudeAgentTeamsSourceCommand = launchOpts.claudeAgentTeamsSourceCommand?.trim() || launchOpts.command?.trim() || undefined const claudeAgentTeamsMode = this.store?.getSettings?.().claudeAgentTeamsMode const effectiveClaudeAgentTeamsMode = inferCapturedClaudeAgentTeamsMode( launchOpts.launchConfig, claudeAgentTeamsSourceCommand, claudeAgentTeamsMode ) let agentTeamsPlan: Awaited> | undefined try { agentTeamsPlan = adoptedBeforeLaunch ? undefined : await buildClaudeAgentTeamsLaunchPlan({ command: claudeAgentTeamsSourceCommand, mode: effectiveClaudeAgentTeamsMode, baseEnv: { ...process.env, ...baseEnv }, createTeamEnv: (shimDir, shimBin) => this.claudeAgentTeams.createLaunchEnv({ leaderHandle: preAllocatedHandle, baseEnv: { ...process.env, ...baseEnv }, shimDir, shimBin }).env }) } catch (error) { releaseStablePaneCreate?.() throw error } const sequencedStartupCommand = agentTeamsPlan && claudeAgentTeamsSourceCommand && launchOpts.command && claudeAgentTeamsSourceCommand !== launchOpts.command ? agentTeamsPlan.command : undefined const effectiveLaunchConfig = launchOpts.launchConfig && agentTeamsPlan ? { ...launchOpts.launchConfig, agentCommand: launchOpts.launchConfig.agentCommand ? effectiveClaudeAgentTeamsMode === 'in-process' || process.platform === 'win32' ? addClaudeTeammateModeInProcess(launchOpts.launchConfig.agentCommand) : addClaudeTeammateModeAuto(launchOpts.launchConfig.agentCommand) : agentTeamsPlan.command, agentEnv: { ...launchOpts.launchConfig.agentEnv, ...agentTeamsPlan.env } } : launchOpts.launchConfig // Why: setup/agent sequencing wraps the PTY launch in a wait shell before // Claude Agent Teams runs. Preserve the direct Claude command separately // so the wrapper can exec the teammate-mode variant after setup completes. const env = this.buildTerminalWorkspaceEnv( workspace, { ...baseEnv, ...(sequencedStartupCommand ? { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: sequencedStartupCommand } : {}) }, paneKey, tabId, agentTeamsPlan?.env ) const terminalColorQueryReplies = launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors() if (launchOpts.signal?.aborted) { throw new Error('client_disconnected') } const persistHostSessionBinding = launchOpts.persistHostSessionBinding || launchOpts.surfaceOwner === false || this.getAvailableAuthoritativeWindow() === null let result: Awaited>> try { result = await this.ptyController.spawn({ cols: 120, rows: 40, cwd, command: sequencedStartupCommand ? launchOpts.command : (agentTeamsPlan?.command ?? launchOpts.command), launchAgent: launchOpts.launchAgent, commandDelivery: 'provider', startupCommandDelivery: launchOpts.startupCommandDelivery, env, envToDelete: mergeTerminalEnvDeletionKeys( launchOpts.envToDelete, agentTeamsPlan?.envToDelete ), resumeProviderSession: launchOpts.resumeProviderSession, telemetry: launchOpts.telemetry, connectionId: workspace.connectionId, worktreeId: workspace.id, preAllocatedHandle, tabId, leafId, ...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}), ...(launchOpts.agentSessionClaim ? { agentSessionEnsure: { claim: launchOpts.agentSessionClaim, surface: { worktreeId: workspace.id, tabId, leafId, terminalHandle: preAllocatedHandle } } } : {}), ...(launchOpts.agentSessionCreateOperationId ? { agentSessionCreateOperationId: launchOpts.agentSessionCreateOperationId } : {}), ...(launchOpts.signal ? { signal: launchOpts.signal } : {}), ...(launchOpts.onPtySpawnCommitted ? { onPtySpawnCommitted: reportPtySpawnCommitted } : {}), ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), // Why: a headless-created pane has no renderer session writer. Persist // its tab/leaf binding at spawn so a later promoted window reattaches // the live daemon or SSH PTY instead of replacing it with a fresh one. // Re-check freshly: the entry-time snapshot can go stale across the // awaits above if the authoritative window is destroyed mid-spawn. ...(persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) }) } finally { releaseStablePaneCreate?.() } if (!result.stablePaneOwner) { reportPtySpawnCommitted() } const adoptedStablePane = Boolean(result.stablePaneOwner) if (result.agentSessionEnsure) { const canonicalSurface = result.agentSessionEnsure.owner.surface preAllocatedHandle = canonicalSurface.terminalHandle tabId = canonicalSurface.tabId leafId = canonicalSurface.leafId paneKey = makePaneKey(tabId, leafId) } else if (result.stablePaneOwner) { preAllocatedHandle = result.stablePaneOwner.handle tabId = result.stablePaneOwner.tabId leafId = result.stablePaneOwner.leafId paneKey = makePaneKey(tabId, leafId) } try { this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId) } catch (error) { if (error instanceof Error && error.message === 'agent_session_exited_during_start') { this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId) } throw error } this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) } this.registerPty(result.id, workspace.id, workspace.connectionId, { tabId, leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { if (persistHostSessionBinding) { pty.runtimeSessionOwned = true } if (!adoptedStablePane) { if (launchOpts.title) { const observedAt = this.nextTitleObservationSequence() pty.title = launchOpts.title pty.titleUpdatedAt = observedAt this.setPtyManagementTitleFromObservedTitle(pty, launchOpts.title, observedAt) } else { pty.title = null pty.titleUpdatedAt = null } pty.launchConfig = effectiveLaunchConfig ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) : null pty.launchToken = launchToken ?? null pty.launchAgent = launchOpts.launchAgent ?? null } pty.tabId = tabId pty.paneKey = paneKey } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle if (pty && !adoptedStablePane && launchOpts.deferMobileSessionPublish !== true) { this.publishPtyBackedMobileSessionTerminal(workspace.id, pty, { tabId, leafId, title: launchOpts.title ?? null, activate: presentation === 'focused', // Why: explicit background presentation may carry legacy activate // metadata from an already-owned renderer pane; don't select it on mobile. selectIfNoActiveTab: presentation !== 'background', ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } let surface: RuntimeTerminalCreate['surface'] = 'background' let warning: string | undefined if (presentation !== 'background' && this.notifier?.revealTerminalSession) { try { // Why: after the PTY is spawned, renderer tab adoption is best-effort; // failing here must not strand a live process without returning a handle. // Pass the pre-minted tabId so the renderer adopts under the same id // already baked into the PTY env — keeps paneKey hook attribution intact. await this.notifier.revealTerminalSession(workspace.id, { ptyId: result.id, title: launchOpts.title ?? null, ...(cwd !== workspace.path ? { cwd } : {}), ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), ...(launchToken ? { launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), ...ownerSurfacing(opts.surfaceOwner !== false), tabId, leafId }) surface = 'visible' } catch (err) { console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err) warning = createTerminalRevealWarning(handle, err) } } else if (presentation !== 'background') { warning = createTerminalRevealWarning(handle) } return { handle, tabId, paneKey, ptyId: result.id, worktreeId: workspace.id, title: pty?.title ?? launchOpts.title ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), ...(adoptedStablePane ? { isReattach: true as const } : {}), ...(warning ? { warning } : {}) } } finally { releaseStablePaneCreate() } } this.assertGraphReady() const win = rendererWindow ?? this.getAuthoritativeWindow() // Why: mirrors browserTabCreate — when no worktree is specified, pass // undefined so the renderer uses its current active worktree. const workspace = worktreeSelector ? await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) : null const launchOpts = workspace ? await this.resolveAgentTerminalCreateOptions(workspace, opts) : opts const worktreeId = workspace?.id const cwd = workspace ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) : launchOpts.cwd const requestId = randomUUID() // Why: terminal creation is a renderer-side Zustand store operation (like // browser tab creation). The main process sends a request, the renderer // creates the tab and replies with the tabId so we can resolve the handle. const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { ipcMain.removeListener('terminal:tabCreateReply', handler) reject(new Error('Terminal creation timed out')) }, 10_000) const handler = ( event: Electron.IpcMainEvent, r: { requestId: string; tabId?: string; title?: string; error?: string } ): void => { if (event.sender !== win.webContents || r.requestId !== requestId) { return } clearTimeout(timer) ipcMain.removeListener('terminal:tabCreateReply', handler) if (r.error) { reject(new Error(r.error)) } else { resolve({ tabId: r.tabId!, title: r.title ?? launchOpts.title ?? '' }) } } ipcMain.on('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, worktreeId, command: launchOpts.command, cwd, ...(launchOpts.env ? { env: launchOpts.env } : {}), ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), ...(launchOpts.resumeProviderSession ? { resumeProviderSession: launchOpts.resumeProviderSession } : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', ...(presentation ? { presentation } : {}), ...ownerSurfacing(opts.surfaceOwner !== false) }) }) // Why: the renderer created the tab immediately, but the graph sync that // populates this.leaves may not have arrived yet. Wait for the leaf to // appear so we can return a valid handle the caller can use right away. const handle = await this.waitForTerminalHandle(reply.tabId) return { handle, tabId: reply.tabId, worktreeId: worktreeId ?? '', title: reply.title, ...this.getPtyExecutionHostMetadata(this.handles.get(handle)?.ptyId ?? null), surface: 'visible' } } async dedupeTerminalCreate( clientIdentity: string, worktreeSelector: string | undefined, clientMutationId: string | undefined, reconcileExisting: boolean, run: ( canonicalWorktreeSelector: string | undefined, preAllocatedHandle: string | undefined ) => Promise ): Promise { if (!clientMutationId || !worktreeSelector) { if (reconcileExisting) { throw new Error('runtime_unavailable') } return await run(worktreeSelector, undefined) } const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const canonicalWorktreeSelector = `id:${workspace.id}` const preAllocatedHandle = deriveRemoteRuntimeTerminalCreateHandle( clientIdentity, workspace.id, clientMutationId ) return this.terminalCreateIdempotency.run( clientIdentity, workspace.id, clientMutationId, async () => { if (reconcileExisting) { const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle) if (adopted) { return adopted } } return await run(canonicalWorktreeSelector, preAllocatedHandle) } ) } private async reconcileRemoteTerminalCreate( worktreeId: string, terminalHandle: string ): Promise { if (!this.ptyController?.listProcesses) { throw new Error('runtime_unavailable') } const listed = await withTimeoutResult( this.ptyController.listProcesses(), PTY_CONTROLLER_LIST_TIMEOUT_MS ) if (!listed.ok) { // Why: unknown inventory cannot prove the first create failed, so spawning could duplicate a live shell. throw new Error('runtime_unavailable') } const matches = listed.value.filter((session) => session.terminalHandle === terminalHandle) if (matches.length > 1) { throw new Error('terminal_create_identity_conflict') } if (matches.length === 0) { const sameWorktreeHasUnknownIdentity = listed.value.some( (session) => (session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)) === worktreeId && !session.terminalHandle ) if (sameWorktreeHasUnknownIdentity) { // Why: older retained providers may list the first shell without its handle; absence is not authoritative in that shape. throw new Error('runtime_unavailable') } return null } const session = matches[0] const authoritativeWorktreeId = session.worktreeId ?? inferWorktreeIdFromPtyId(session.id) if (authoritativeWorktreeId !== worktreeId) { // Why: a reused address or forged provider record must never adopt a PTY from another workspace. throw new Error('terminal_create_identity_conflict') } this.adoptControllerTerminalHandle(session.id, terminalHandle) const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, title: session.title }) const adoptedHandle = this.issuePtyHandle(pty) if (adoptedHandle !== terminalHandle) { throw new Error('terminal_create_identity_conflict') } return { handle: adoptedHandle, ptyId: session.id, worktreeId, title: session.title || null, surface: 'background' } } private getPtyExecutionHostMetadata( ptyId: string | null ): Pick { if (!ptyId) { return {} } const pty = this.ptysById.get(ptyId) if (!pty) { return {} } if (pty.connectionId) { const remotePlatform = getRegisteredSshState(pty.connectionId)?.remotePlatform return { executionHostId: toSshExecutionHostId(pty.connectionId), ...(remotePlatform ? { hostPlatform: remotePlatform } : {}) } } return { executionHostId: LOCAL_EXECUTION_HOST_ID, hostPlatform: pty.isWsl || pty.wslDistro ? 'linux' : process.platform } } async launchAgentTerminal( worktreeSelector: string, opts: { agent: TuiAgent; prompt: string; title?: string } ): Promise { const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) if (!repo) { throw new Error('Repository for the selected workspace is no longer available.') } const startup = this.buildStartupForAgent(repo, opts.agent, opts.prompt) if (repo.connectionId) { await this.markRemoteWorkspaceTrustedForAgent(opts.agent, repo.connectionId, worktree.path) } else { this.markLocalWorkspaceTrustedForAgent(opts.agent, worktree.path) } return await this.createTerminal(`id:${worktree.id}`, { command: startup.startup.command, env: startup.startup.env, ...(startup.startup.launchConfig ? { launchConfig: startup.startup.launchConfig } : {}), launchAgent: startup.agent, startupCommandDelivery: startup.startup.startupCommandDelivery, telemetry: startup.startup.telemetry, title: opts.title }) } // Why: dedupes a worktree.create whose response was lost when a mobile // connection migration (relay/direct hand-off on shoddy cellular) rejected the // in-flight request. A retry with the same clientMutationId returns the // in-flight or just-finished create instead of a duplicate worktree; failures // drop immediately so a genuine retry starts fresh, and successes linger // briefly so a retry whose response was lost in the cutover still reconciles. dedupeWorktreeCreate( repoSelector: string, clientMutationId: string | undefined, run: () => Promise ): Promise { if (!clientMutationId) { return run() } const key = `${repoSelector}\0${clientMutationId}` const inflight = this.worktreeCreateByMutationId.get(key) if (inflight) { return inflight as Promise } const created = run() this.worktreeCreateByMutationId.set(key, created) const drop = (): void => { if (this.worktreeCreateByMutationId.get(key) === created) { this.worktreeCreateByMutationId.delete(key) } } void created.then(() => { setTimeout(drop, WORKTREE_CREATE_RESULT_TTL_MS).unref?.() }, drop) return created } async createMobileSessionTerminal( worktreeSelector: string, opts: { afterTabId?: string targetGroupId?: string command?: string cwd?: string env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' activate?: boolean select?: boolean clientNavigationId?: string navigation?: RuntimeNavigationTarget clientMutationId?: string signal?: AbortSignal } = {} ): Promise { const navigation = opts.navigation ?? 'all' const select = opts.select ?? opts.activate !== false const runOpts = { ...opts, activate: select && navigationTargetsHost(navigation) } const mutationId = opts.clientMutationId let result: RuntimeMobileSessionCreateTerminalResult if (!mutationId) { result = await this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) } else { // Why: idempotency is caller-owned; two paired devices may reuse the same mutation id without sharing a result. const mutationKey = `${opts.clientNavigationId ?? 'local'}\0${worktreeSelector}\0${mutationId}` // Why: a retried create (double-tap, reconnect replay) with the same // idempotency key must return the in-flight operation instead of spawning a // duplicate terminal. Successes are kept briefly so a retry whose response // was lost in transit reuses the created terminal; failures are dropped // immediately so a retry can start a fresh create. const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) const run = inflight ?? this.runCreateMobileSessionTerminal(worktreeSelector, runOpts) if (!inflight) { this.mobileTerminalCreateByMutationId.set(mutationKey, run) const drop = (): void => { if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { this.mobileTerminalCreateByMutationId.delete(mutationKey) } } void run.then(() => { setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() }, drop) } result = await run } if (select) { const worktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.applyMobileSessionTabNavigation( this.getMobileSessionTabsForWorktree(worktreeId), result.tab.id, navigation, opts.clientNavigationId ) } return result } private async runCreateMobileSessionTerminal( worktreeSelector: string, opts: { afterTabId?: string targetGroupId?: string command?: string cwd?: string env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' activate?: boolean clientNavigationId?: string clientMutationId?: string signal?: AbortSignal } = {} ): Promise { const pairedCreate = Boolean(opts.clientNavigationId) const graphEpoch = this.captureReadyGraphEpoch() const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const worktreeId = workspace.id const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) let afterDesktopTabId: string | undefined if (opts.afterTabId) { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const anchor = snapshot?.tabs.find((tab) => tab.id === opts.afterTabId) if (!anchor) { throw new Error('after_tab_not_found') } afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id } const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts) this.assertStableReadyGraph(graphEpoch) if (opts.signal?.aborted) { throw new Error('client_disconnected') } const win = this.getAvailableAuthoritativeWindow() if (!win) { return await this.createRuntimeOwnedMobileSessionTerminal( worktreeId, opts.activate !== false, opts.afterTabId, { command: startupCommand.command, cwd, env: startupCommand.env, envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig, signal: opts.signal } ) } if (win.webContents.isDestroyed?.()) { throw new Error('runtime_unavailable') } const releasePublicationThrottle = pairedCreate ? this.rendererPublicationThrottle.acquire(win.webContents) : () => {} try { const requestId = randomUUID() const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => { const timer = setTimeout(() => { ipcMain.removeListener('terminal:tabCreateReply', handler) opts.signal?.removeEventListener('abort', onAbort) reject(new Error('Terminal creation timed out')) }, 10_000) // Why: a dead client connection cancels the wait; the renderer tab (and // its shell) stays alive for the host and mirrors on reconnect (#7718). const onAbort = (): void => { clearTimeout(timer) ipcMain.removeListener('terminal:tabCreateReply', handler) reject(new Error('client_disconnected')) } const handler = ( event: Electron.IpcMainEvent, r: { requestId: string; tabId?: string; title?: string; error?: string } ): void => { if (event.sender !== win.webContents || r.requestId !== requestId) { return } clearTimeout(timer) ipcMain.removeListener('terminal:tabCreateReply', handler) opts.signal?.removeEventListener('abort', onAbort) if (r.error) { reject(new Error(r.error)) } else { resolve({ tabId: r.tabId!, title: r.title ?? '' }) } } opts.signal?.addEventListener('abort', onAbort, { once: true }) ipcMain.on('terminal:tabCreateReply', handler) win.webContents.send('terminal:requestTabCreate', { requestId, worktreeId, afterTabId: afterDesktopTabId, targetGroupId: opts.targetGroupId, command: startupCommand.command, cwd, ...(startupCommand.env ? { env: startupCommand.env } : {}), ...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', activate: opts.activate }) }) if (opts.activate !== false) { this.notifier?.focusTerminal(reply.tabId, worktreeId, null) } // Why: register the wait before the renderer's PTY spawn arrives so that // spawn (registerPty) can publish the pty-backed surface main-side even if // graph-sync is stalled (#7587). Removed in the finally below. const pendingCreateKey = `${worktreeId}::${reply.tabId}` // Why: a rescue publishes into the active group (opts.targetGroupId is not // threaded); the renderer's reconciling publication then moves the tab to the // requested group, so any wrong-group placement is cosmetic and stall-window-only. this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { activate: opts.activate !== false, paired: pairedCreate, selectIfNoActiveTab: true, ...(startupCommand.command ? { startupCommand: startupCommand.command } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) }) try { // Why: the PTY spawn and the tabCreate reply race on independent IPC // channels; if the spawn already registered, publish immediately so the // wait resolves without depending on a graph sync. this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS, signal: opts.signal }) if (this.isReadyMobileTerminalSurface(surface)) { this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) return surface } const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, { timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS, requireReady: true, signal: opts.signal }).catch(() => null) if (readySurface) { this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) return readySurface } if (opts.signal?.aborted) { // Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab. throw new Error('client_disconnected') } const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId) if (!pendingSurface) { throw new Error('Timed out waiting for terminal surface after creation') } // Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab. return await this.createRuntimeOwnedMobileSessionTerminal( worktreeId, opts.activate !== false, opts.afterTabId, { command: startupCommand.command, cwd, env: startupCommand.env, envToDelete: startupCommand.envToDelete, startupCommandDelivery: startupCommand.startupCommandDelivery, identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig, signal: opts.signal } ) } catch (error) { // Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587). if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) if (rescued) { this.deliverPendingStartupCommandToBareRendererPty(worktreeId, reply.tabId) return rescued } } // Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718). if ( isClientDisconnectedError(error) || this.hasLiveShellForRendererTab(worktreeId, reply.tabId) ) { throw error } // Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots. this.notifier?.closeTerminal(reply.tabId) throw error } finally { this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) } } finally { releasePublicationThrottle() } } private async resolveMobileSessionTerminalCommand( workspace: TerminalWorkspaceLaunchScope, opts: { command?: string env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] agent?: TuiAgent agentPrompt?: string launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent } ): Promise<{ command?: string env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent }> { if (opts.command || !opts.agent) { return { command: opts.command, env: opts.env, envToDelete: opts.envToDelete, launchConfig: opts.launchConfig, launchAgent: opts.launchAgent, startupCommandDelivery: opts.startupCommandDelivery } } if (!this.store) { throw new Error('runtime_unavailable') } const settings = this.store.getSettings() if (!isTuiAgentEnabled(opts.agent, settings.disabledTuiAgents)) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } // Why: mobile may be iOS while the shell host is Windows/macOS/Linux or SSH Linux; quote for the host shell. const platform = this.getAgentLaunchPlatformForWorkspace(workspace) // Why: SSH runs the CLI through the relay shim (plain `orca`), so the Linux-only `orca-ide` rename must not apply. const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace) const queuedShell = resolveLocalWindowsAgentStartupShell({ platform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) const startupPlan = buildAgentStartupPlan({ agent: opts.agent, prompt: opts.agentPrompt ?? '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv), platform, shell: queuedShell, isRemote, allowEmptyPromptLaunch: true }) if (!startupPlan) { throw new Error(`Could not build launch command for ${opts.agent}.`) } if (opts.agentPrompt && startupPlan.followupPrompt) { throw new Error(`Agent ${opts.agent} does not support startup prompt quick commands.`) } if (workspace.connectionId) { await this.markRemoteWorkspaceTrustedForAgent( opts.agent, workspace.connectionId, workspace.path ) } else { this.markLocalWorkspaceTrustedForAgent(opts.agent, workspace.path) } return { command: startupPlan.launchCommand, env: startupPlan.env, // Why: a real-home Codex resume strips inherited CODEX_HOME via // envToDelete; dropping it here would resume against the wrong home. envToDelete: opts.envToDelete, launchConfig: startupPlan.launchConfig, launchAgent: opts.agent, startupCommandDelivery: startupPlan.startupCommandDelivery } } private async createRuntimeOwnedMobileSessionTerminal( worktreeId: string, activate: boolean, afterTabId?: string, opts: { command?: string cwd?: string env?: Record envToDelete?: string[] startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent viewMode?: 'terminal' | 'chat' targetGroupId?: string launchConfig?: SleepingAgentLaunchConfig signal?: AbortSignal } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) // Why: SshPtyProvider treats sessionId as a relay reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn. const stableSessionId = opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`) const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false, command: opts.command, cwd, env: opts.env, envToDelete: opts.envToDelete, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { tabId: opts.identity.tabId, leafId: opts.identity.leafId, ...(stableSessionId ? { sessionId: stableSessionId } : {}) } : stableSessionId ? { sessionId: stableSessionId } : {}), ...(isNewSession ? { isNewSession: true } : {}), persistHostSessionBinding: true, // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. deferMobileSessionPublish: true, signal: opts.signal }) const livePty = this.getLivePtyForHandle(terminal.handle) if (!livePty) { throw new Error('terminal_handle_stale') } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() if (opts.viewMode) { // Why: the runtime-owned binding must survive a serve restart with the same initial mode, not a later client's local default. this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && candidate.parentTabId === parentTabId && candidate.leafId === leafId ) ?? null const parentLayout = this.buildMaterializedHeadlessParentLayout( leafId, livePty.pty.ptyId, existingSurface?.parentLayout ) const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${parentTabId}::${leafId}`, parentTabId, leafId, ptyId: livePty.pty.ptyId, title: terminal.title ?? livePty.pty.title ?? 'Terminal', ...(cwd ? { startupCwd: cwd } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } const tabs = (existing?.tabs ?? []) .filter((candidate) => candidate.id !== tab.id) .map((candidate) => ({ ...candidate, ...(candidate.type === 'terminal' && candidate.parentTabId === parentTabId ? { parentLayout } : {}), isActive: activate ? false : candidate.isActive })) const insertAfter = afterTabId ? tabs.findIndex((candidate) => candidate.id === afterTabId) : -1 if (insertAfter >= 0) { tabs.splice(insertAfter + 1, 0, tab) } else { tabs.push(tab) } const next: RuntimeMobileSessionTabsSnapshot = { worktree: worktreeId, publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, // Why: activating the new tab also focuses its group, so a "+" targeting a specific split group makes that group active too. activeGroupId: activate && opts.targetGroupId ? opts.targetGroupId : (existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId)), activeTabId: activate ? tab.id : (existing?.activeTabId ?? null), activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null), tabGroups: this.buildHeadlessMobileSessionTabGroups( worktreeId, tabs, activate ? tab : null, existing?.tabGroups, opts.targetGroupId ? { tabId: parentTabId, groupId: opts.targetGroupId } : undefined ), // Why: keep group split geometry on new-tab creation, else opening a terminal while split loses the arrangement. ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } this.mobileSessionTabsByWorktree.set(worktreeId, next) const result = this.toMobileSessionTabsResult(next) for (const subscription of this.mobileSessionTabListeners) { subscription.listener( this.clientSessionTabSelections.project(result, subscription.clientNavigationId) ) } const created = result.tabs.find((candidate) => candidate.id === tab.id) if (!created || created.type !== 'terminal') { throw new Error('terminal_handle_stale') } return { tab: created, publicationEpoch: result.publicationEpoch, snapshotVersion: result.snapshotVersion } } private waitForMobileTerminalSurface( worktreeId: string, parentTabId: string, options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {} ): Promise { const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options) if (existing) { return Promise.resolve(existing) } if (options.signal?.aborted) { return Promise.reject(new Error('client_disconnected')) } return new Promise((resolve, reject) => { const cleanup = (): void => { clearTimeout(timer) options.signal?.removeEventListener('abort', onAbort) const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } } const timer = setTimeout(() => { cleanup() reject(new Error('Timed out waiting for terminal surface after creation')) }, timeoutMs) // Why: a dead client connection cancels the wait immediately instead of running down the timeout into rollback (#7718). const onAbort = (): void => { cleanup() reject(new Error('client_disconnected')) } options.signal?.addEventListener('abort', onAbort, { once: true }) const check = (): void => { const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options) if (!next) { return } cleanup() resolve(next) } this.graphSyncCallbacks.push(check) check() }) } private findMobileTerminalSurface( worktreeId: string, parentTabId: string, options: { requireReady?: boolean } = {} ): RuntimeMobileSessionCreateTerminalResult | null { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { return null } const result = this.toMobileSessionTabsResult(snapshot) const tab = result.tabs.find( (candidate) => candidate.type === 'terminal' && candidate.parentTabId === parentTabId ) if (!tab || tab.type !== 'terminal') { return null } const surface = { tab, publicationEpoch: result.publicationEpoch, snapshotVersion: result.snapshotVersion } if (options.requireReady === true && !this.isReadyMobileTerminalSurface(surface)) { return null } return surface } private findMobileTerminalSurfaceForPty( worktreeId: string, ptyId: string ): RuntimeMobileSessionCreateTerminalResult | null { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( (candidate) => candidate.type === 'terminal' && (candidate.ptyId === ptyId || candidate.parentLayout?.ptyIdsByLeafId?.[candidate.leafId] === ptyId) ) return tab?.type === 'terminal' ? this.findMobileTerminalSurface(worktreeId, tab.parentTabId) : null } // Why: publish an in-flight mobile create main-side from the live PTY so it can't stall on graph sync and destroy the session (#7587). private ensurePtyBackedMobileSurfaceForRendererTab( worktreeId: string, tabId: string ): RuntimeMobileSessionCreateTerminalResult | null { const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) if (!pending) { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) if (pty) { pty.runtimeSessionOwned = true if (pending.paired) { this.setPairedRendererSessionOwnership(pty.ptyId, true) } } if ( existing && this.isReadyMobileTerminalSurface(existing) && (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) ) { // Why: the renderer's ready publication already landed with the intended mode; only a pending shell needs the main-side rescue. return existing } const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { return existing } this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { tabId, leafId, title: null, activate: pending.activate, selectIfNoActiveTab: pending.selectIfNoActiveTab, ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) }) // Why: check closures normally drain only inside syncWindowGraph; a main-side publish must drain them too or the pending wait misses the insertion. for (const cb of [...this.graphSyncCallbacks]) { cb() } return this.findMobileTerminalSurface(worktreeId, tabId) } private restoreLivePairedRendererSessionOwnedMobileTerminals( worktreeId: string | null, options: { missingSnapshotOnly?: boolean; notify?: boolean } = {} ): void { for (const ptyId of this.pairedRendererSessionOwnedPtyIds) { const pty = this.ptysById.get(ptyId) if ( !pty?.connected || !pty.tabId || (worktreeId !== null && !runtimeWorktreeIdsEqual(pty.worktreeId, worktreeId)) ) { continue } const targetWorktreeId = worktreeId ?? pty.worktreeId const pane = parsePaneKey(pty.paneKey ?? '') if (!pane || pane.tabId !== pty.tabId) { continue } const existing = this.mobileSessionTabsByWorktree.get(targetWorktreeId) if (existing && options.missingSnapshotOnly) { continue } if ( existing?.tabs.some( (tab) => tab.type === 'terminal' && (tab.ptyId === pty.ptyId || (tab.parentTabId === pty.tabId && tab.leafId === pane.leafId)) ) ) { continue } if (!existing) { this.mobileSessionTabsByWorktree.set(targetWorktreeId, { worktree: targetWorktreeId, publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`, snapshotVersion: 0, activeGroupId: null, activeTabId: null, activeTabType: null, tabGroups: [], tabs: [] }) } this.publishPtyBackedMobileSessionTerminal(targetWorktreeId, pty, { tabId: pty.tabId, leafId: pane.leafId, title: null, activate: false, selectIfNoActiveTab: false, notify: options.notify }) } } private setPairedRendererSessionOwnership(ptyId: string, owned: boolean): void { if (owned) { this.pairedRendererSessionOwnedPtyIds.add(ptyId) } else { this.pairedRendererSessionOwnedPtyIds.delete(ptyId) } } private findLiveRegisteredPtyForRendererTab( worktreeId: string, tabId: string ): RuntimePtyWorktreeRecord | null { for (const pty of this.ptysById.values()) { if ( pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected && parsePaneKey(pty.paneKey ?? '')?.leafId ) { return pty } } return null } // Why: looser rollback guard than findLiveRegisteredPtyForRendererTab — a shell without a registered pane key is still a real terminal the timeout must not kill (#7718). private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean { for (const pty of this.ptysById.values()) { if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) { return true } } return false } private isReadyMobileTerminalSurface( surface: RuntimeMobileSessionCreateTerminalResult | null ): boolean { return ( surface?.tab.status === 'ready' && typeof surface.tab.terminal === 'string' && surface.tab.terminal.length > 0 ) } // Why: a create can settle over a renderer PTY that spawned without its // startup command (the create's renderer stalled, #7587), silently binding // the client to a plain shell under an agent tab forever — once the surface // is ready, the activation-time materialize recovery (#7837) never runs // (STA-3214). Spawn commands are recorded per PTY at spawn time, so a // missing record on the locally registered live PTY proves the launch never // ran; type it into the shell like the create would have. private deliverPendingStartupCommandToBareRendererPty(worktreeId: string, tabId: string): void { const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) const command = pending?.startupCommand if (!command) { return } const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) if (!pty || this.terminalSpawnCommandsByPtyId.has(pty.ptyId)) { return } if (this.ptyController?.write(pty.ptyId, command)) { // Why: Enter rides its own write so a long command cannot swallow it. this.ptyController.write(pty.ptyId, '\r') this.noteTerminalSpawnCommand(pty.ptyId, command) } } private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise { const existing = this.resolveHandleForTab(tabId) if (existing) { return Promise.resolve(existing) } return new Promise((resolve, reject) => { const timer = setTimeout(() => { const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } reject(new Error('Timed out waiting for terminal handle after creation')) }, timeoutMs) const check = (): void => { const handle = this.resolveHandleForTab(tabId) if (handle) { clearTimeout(timer) const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } resolve(handle) } } this.graphSyncCallbacks.push(check) // Why: graph sync may have fired between the initial check and registration; re-check to avoid a missed wake-up. check() }) } // Why: mobile may subscribe before the PTY spawns; wait for it so subscribe proceeds with phone-fit instead of a bare scrollback+end. waitForLeafPtyId(handle: string, timeoutMs = 10_000, signal?: AbortSignal): Promise { const leaf = this.resolveLeafForHandle(handle) if (leaf?.ptyId) { return Promise.resolve(leaf.ptyId) } // Why: ptyId null→real invalidates the old handle; capture tabId+leafId now for direct leaf lookup afterward. const record = this.handles.get(handle) const savedTabId = record?.tabId ?? null const savedLeafId = record?.leafId ?? null return new Promise((resolve, reject) => { let timer: ReturnType | null = null let check: () => void = () => {} const cleanup = (): void => { if (timer) { clearTimeout(timer) timer = null } const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } signal?.removeEventListener('abort', onAbort) } const finish = (ptyId: string): void => { cleanup() resolve(ptyId) } const fail = (error: Error): void => { cleanup() reject(error) } const onAbort = (): void => { fail(new Error('request_aborted')) } if (signal?.aborted) { reject(new Error('request_aborted')) return } signal?.addEventListener('abort', onAbort, { once: true }) timer = setTimeout(() => { fail(new Error('Timed out waiting for PTY to spawn')) }, timeoutMs) check = (): void => { // Try the handle first (works if handle wasn't invalidated yet) let ptyId = this.resolveLeafForHandle(handle)?.ptyId // Why: ptyId null→real invalidates the old handle; fall back to direct leaf lookup by saved coordinates. if (!ptyId && savedTabId && savedLeafId) { const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId)) ptyId = directLeaf?.ptyId ?? null } if (ptyId) { finish(ptyId) } } this.graphSyncCallbacks.push(check) check() }) } // Why: never-mounted tabs have no PTY or snapshot; synthetic handles need the ptyId to mount the exact owning tab. requestRendererTerminalTabMount(handle: string): boolean { const record = this.handles.get(handle) if (!record?.worktreeId) { return false } const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId const ptyId = record.ptyId ?? undefined if (!tabId && !ptyId) { return false } try { this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { worktreeId: record.worktreeId, ...(tabId ? { tabId } : {}), ...(ptyId ? { ptyId } : {}) }) return true } catch { // No authoritative window (shutdown/headless): subscribe keeps its empty-snapshot fallback. return false } } getRendererTerminalSerializerGeneration(ptyId: string): number { return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 } getRendererTerminalSerializerGenerationForHandle(handle: string): number { const ptyId = this.handles.get(handle)?.ptyId return ptyId ? this.getRendererTerminalSerializerGeneration(ptyId) : 0 } replaceHeadlessTerminalFromRendererSnapshotForRecovery( ptyId: string, snapshot: { data: string cols: number rows: number cwd?: string | null oscLinks?: TerminalOscLinkRange[] }, trailingOutput: { data: string; seq: number }[] = [] ): void { if (!snapshot.data) { return } // Why: a redraw byte can create a suffix-only model before the renderer settles; replace it with the exact snapshot already sent mobile. this.providerSnapshotPreferredPtys.add(ptyId) this.disposeHeadlessTerminal(ptyId) this.seedHeadlessTerminal( ptyId, snapshot.data, { cols: snapshot.cols, rows: snapshot.rows }, { cwd: snapshot.cwd, oscLinks: snapshot.oscLinks } ) for (const chunk of trailingOutput) { this.trackHeadlessTerminalData(ptyId, chunk.data, chunk.seq) } // The seed's write chain owns subsequent live bytes; suppress on-data hydration from replacing this known-good seed. this.headlessHydrationState.set(ptyId, 'done') } waitForRendererTerminalSerializer( ptyId: string, afterGeneration: number, timeoutMs?: number, signal?: AbortSignal ): Promise { return ( this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? Promise.resolve(false) ) } // Why: a leaf exists before its PTY spawns; a handle issued while ptyId is null gets invalidated on the next sync, so wait for a connected PTY. private countLeavesInTab(tabId: string): number { let count = 0 for (const leaf of this.leaves.values()) { if (leaf.tabId === tabId) { count++ } } return count } private resolveHandleForTab(tabId: string): string | null { for (const leaf of this.leaves.values()) { if (leaf.tabId === tabId && leaf.ptyId !== null) { return this.issueHandle(leaf) } } return null } async focusTerminal( handle: string, options: { navigateHost?: boolean } = {} ): Promise { const navigateHost = options.navigateHost !== false const livePtyIdentity = (): RuntimeTerminalFocus => { const live = this.getLivePtyForHandle(handle) if (!live?.pty.connected) { throw new Error('terminal_exited') } return { handle, tabId: live.pty.tabId ?? live.record.tabId, worktreeId: live.pty.worktreeId, navigated: false } } const liveLeafIdentity = (): RuntimeTerminalFocus => { this.assertGraphReady() const { leaf: current } = this.getLiveLeafForHandle(handle) return { handle, tabId: current.tabId, worktreeId: current.worktreeId, navigated: false } } const pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_exited') } if (!navigateHost || !this.notifier?.revealTerminalSession) { return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, worktreeId: pty.pty.worktreeId, navigated: false } } // Coalesce concurrent host navigations: only the latest full reveal claims navigated. return this.terminalFocusNavigationCoalescer.run({ key: handle, resolveSuperseded: (completed) => completed ? { ...completed, navigated: false } : livePtyIdentity(), run: async (ctx) => { const live = this.getLivePtyForHandle(handle) if (!live?.pty.connected) { throw new Error('terminal_exited') } if (!ctx.isCurrent()) { return { handle, tabId: live.pty.tabId ?? live.record.tabId, worktreeId: live.pty.worktreeId, navigated: false } } const notifier = this.notifier if (!notifier?.revealTerminalSession) { return { handle, tabId: live.pty.tabId ?? live.record.tabId, worktreeId: live.pty.worktreeId, navigated: false } } const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '') const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, { ptyId: live.pty.ptyId, title: getLatestPtyTitle(live.pty), ...(live.pty.launchConfig ? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) } : {}), ...(live.pty.launchToken ? { launchToken: live.pty.launchToken } : {}), ...(live.pty.launchAgent ? { launchAgent: live.pty.launchAgent } : {}), ...(live.pty.tabId !== null ? { tabId: live.pty.tabId } : {}), ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) }) if (!ctx.isCurrent() || this.notifier !== notifier) { return { handle, tabId: revealed?.tabId ?? live.pty.tabId ?? live.record.tabId, worktreeId: live.pty.worktreeId, navigated: false } } return { handle, tabId: revealed?.tabId ?? live.pty.tabId ?? live.record.tabId, worktreeId: live.pty.worktreeId, navigated: true } } }) } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) if (!navigateHost) { return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId, navigated: false } } if (!this.notifier?.focusTerminal) { return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId, navigated: false } } return this.terminalFocusNavigationCoalescer.run({ key: handle, resolveSuperseded: (completed) => completed ? { ...completed, navigated: false } : liveLeafIdentity(), run: async (ctx) => { this.assertGraphReady() const { leaf: liveLeaf } = this.getLiveLeafForHandle(handle) if (!ctx.isCurrent()) { return { handle, tabId: liveLeaf.tabId, worktreeId: liveLeaf.worktreeId, navigated: false } } const notifier = this.notifier if (!notifier?.focusTerminal) { return { handle, tabId: liveLeaf.tabId, worktreeId: liveLeaf.worktreeId, navigated: false } } notifier.focusTerminal(liveLeaf.tabId, liveLeaf.worktreeId, liveLeaf.leafId) if (!ctx.isCurrent() || this.notifier !== notifier) { return { handle, tabId: liveLeaf.tabId, worktreeId: liveLeaf.worktreeId, navigated: false } } return { handle, tabId: liveLeaf.tabId, worktreeId: liveLeaf.worktreeId, navigated: true } } }) } async closeTerminal(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) if (pty) { // Why: PTY exit can immediately replace a ready SSH publication with a pending one, so capture its durable HUB surface before killing it. const surface = (pty.pty.tabId ? this.findMobileTerminalSurface(pty.pty.worktreeId, pty.pty.tabId) : null) ?? this.findMobileTerminalSurfaceForPty(pty.pty.worktreeId, pty.pty.ptyId) const tabId = surface?.tab.parentTabId ?? pty.pty.tabId ?? pty.record.tabId // Why: relay recovery can leave stale renderer leaves; the persisted HUB layout defines whether closing this PTY closes the whole surface. const siblingCount = surface?.tab.parentLayout ? countTerminalLayoutLeaves(surface.tab.parentLayout.root) : this.countLeavesInTab(tabId) const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false if (!ptyKilled || siblingCount <= 1) { if (surface) { // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId) } catch (error) { if (!(error instanceof Error) || error.message !== 'workspace_session_unavailable') { throw error } this.notifier?.closeTerminal(tabId) } } else { this.notifier?.closeTerminal(tabId) } } return { handle, tabId, ptyKilled } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) let ptyKilled = false if (leaf.ptyId) { ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false } // Why: in a multi-pane tab, killing the PTY is enough (renderer's exit handler closes the pane); an extra IPC close would race it and close the whole tab. const siblingCount = this.countLeavesInTab(leaf.tabId) if (!ptyKilled || siblingCount <= 1) { this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) } return { handle, tabId: leaf.tabId, ptyKilled } } async closeTerminalTab(handle: string): Promise { const pty = this.getLivePtyForHandle(handle) if (pty) { const tabId = pty.pty.tabId if (!tabId) { return this.closeTerminal(handle) } // Why: a handle-addressed CLI/automation close is an explicit intent, so // it must stay destructive under the non-user close adjudication gate. await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { reason: 'user' }) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) return { handle, tabId, closeMode: 'tab', ptyKilled: false } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user' }) this.claudeAgentTeams.removeTeamForLeaderHandle(handle) return { handle, tabId: leaf.tabId, closeMode: 'tab', ptyKilled: false } } async splitTerminal( handle: string, opts: { direction?: 'horizontal' | 'vertical' command?: string env?: Record envToDelete?: string[] activate?: boolean // Why: same split as createTerminal — adopt the pane without revealing its // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource } = {} ): Promise { const livePty = this.getLivePtyForHandle(handle) if (livePty) { return await this.splitPtyBackedTerminal(livePty.pty, opts) } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) const direction = opts.direction ?? 'horizontal' // Snapshot current leaf keys so the post-split graph-sync delta reveals the new pane. const leafKeysBefore = new Set() for (const [key, l] of this.leaves) { if (l.tabId === leaf.tabId) { leafKeysBefore.add(key) } } this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { direction, command: opts.command, telemetrySource: opts.telemetrySource }) const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore) return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId } } private async splitPtyBackedTerminal( pty: RuntimePtyWorktreeRecord, opts: { direction?: 'horizontal' | 'vertical' command?: string env?: Record envToDelete?: string[] activate?: boolean // Why: same split as createTerminal — adopt the pane without revealing its // workspace, for splits the user never asked to see. surfaceOwner?: false telemetrySource?: TerminalPaneSplitSource } = {} ): Promise { if (!this.ptyController?.spawn) { throw new Error('runtime_unavailable') } if (!pty.connected) { throw new Error('terminal_exited') } const parsedPaneKey = parsePaneKey(pty.paneKey ?? '') const parentTabId = pty.tabId?.trim() if (!parentTabId || !parsedPaneKey) { throw new Error('terminal_handle_stale') } const direction = opts.direction ?? 'horizontal' const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${pty.worktreeId}`) const leafId = randomUUID() const preAllocatedHandle = this.createPreAllocatedTerminalHandle() const paneKey = makePaneKey(parentTabId, leafId) const result = await this.ptyController.spawn({ cols: 120, rows: 40, cwd: workspace.path, command: opts.command, commandDelivery: 'provider', env: this.buildTerminalWorkspaceEnv(workspace, opts.env ?? {}, paneKey, parentTabId), envToDelete: opts.envToDelete, connectionId: workspace.connectionId, worktreeId: workspace.id, preAllocatedHandle, tabId: parentTabId, leafId, persistHostSessionBinding: true }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) } this.registerPty(result.id, workspace.id, workspace.connectionId) const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) if (createdPty) { createdPty.tabId = parentTabId createdPty.paneKey = paneKey createdPty.runtimeSessionOwned = pty.runtimeSessionOwned this.setPairedRendererSessionOwnership( createdPty.ptyId, this.pairedRendererSessionOwnedPtyIds.has(pty.ptyId) ) } try { await this.notifier?.revealTerminalSession?.(workspace.id, { ptyId: result.id, title: null, activate: opts.activate !== false, ...ownerSurfacing(opts.surfaceOwner !== false), tabId: parentTabId, leafId, splitFromLeafId: parsedPaneKey.leafId, splitDirection: direction, splitTelemetrySource: opts.telemetrySource }) } catch (error) { this.setPairedRendererSessionOwnership(result.id, false) this.ptyController.kill?.(result.id) throw error } if (createdPty) { this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, { tabId: parentTabId, leafId, title: null, activate: opts.activate !== false, split: { splitFromLeafId: parsedPaneKey.leafId, direction } }) // Why: persist the split so a later snapshot rebuild keeps it instead of collapsing to a single pane. this.persistHeadlessTerminalSplit({ worktreeId: workspace.id, tabId: parentTabId, leafId, ptyId: createdPty.ptyId, splitFromLeafId: parsedPaneKey.leafId, direction }) } return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 } } async handleAgentTeamsTmuxCompat( request: AgentTeamsTmuxCompatRequest ): Promise { return await this.claudeAgentTeams.handleTmuxCompat(request, { splitTerminal: (handle, opts) => this.splitTerminal(handle, opts), readTerminal: (handle, opts) => this.readTerminal(handle, opts), sendTerminal: (handle, action) => this.sendTerminal(handle, action), focusTerminal: (handle) => this.focusTerminal(handle), closeTerminal: (handle) => this.closeTerminal(handle), showTerminal: (handle) => this.showTerminal(handle) }) } async prepareClaudeAgentTeamsLeader(args: { paneKey: string baseEnv?: Record }): Promise<{ env: Record }> { const handle = this.getTerminalHandleForPaneKey(args.paneKey) if (!handle) { throw new Error('claude_agent_teams_requires_orca_terminal') } return await this.prepareClaudeAgentTeamsLeaderForHandle({ handle, baseEnv: args.baseEnv }) } async prepareClaudeAgentTeamsLeaderForHandle(args: { handle: string baseEnv?: Record }): Promise<{ env: Record }> { const baseEnv = { ...process.env, ...args.baseEnv } const shimDir = await ensureClaudeAgentTeamsShimDir() const shimBin = resolveClaudeAgentTeamsShimBin(baseEnv) return this.claudeAgentTeams.createLaunchEnv({ leaderHandle: args.handle, baseEnv, shimDir, shimBin }) } private waitForNewLeafInTab( tabId: string, existingLeafKeys: Set, timeoutMs = 10_000 ): Promise { const tryResolve = (): string | null => { for (const [key, leaf] of this.leaves) { if (leaf.tabId === tabId && !existingLeafKeys.has(key) && leaf.ptyId !== null) { return this.issueHandle(leaf) } } return null } const existing = tryResolve() if (existing) { return Promise.resolve(existing) } return new Promise((resolve, reject) => { const timer = setTimeout(() => { const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } reject(new Error('Timed out waiting for split pane handle')) }, timeoutMs) const check = (): void => { const handle = tryResolve() if (handle) { clearTimeout(timer) const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } resolve(handle) } } this.graphSyncCallbacks.push(check) check() }) } async stopTerminalsForWorktree( worktreeSelector: string, options: { deadline?: number stopPty?: ( ptyId: string, stop: () => boolean | Promise ) => Promise<{ stopped: boolean; owner: boolean }> /** Authoritative id for an orphan whose selector no longer resolves. */ resolvedWorktreeId?: string resolvedConnectionId?: string resolvedRuntimeEnvironmentId?: string } = {} ): Promise<{ stopped: number }> { // Why: this mutates live PTYs, so reject while the graph is reloading rather than act on cached leaf ownership. const graphEpoch = this.captureReadyGraphEpoch() const worktree = options.resolvedWorktreeId ? { id: options.resolvedWorktreeId } : await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) if (options.deadline !== undefined && Date.now() >= options.deadline) { return { stopped: 0 } } // Preserve folder-instance suffixes while normalizing cross-platform path spelling. const parsedTarget = splitWorktreeId(worktree.id) const ownsWorktree = options.resolvedWorktreeId ? (candidate: string | undefined): boolean => { if (!candidate) { return false } const parsedCandidate = splitWorktreeId(candidate) return parsedCandidate && parsedTarget ? parsedCandidate.repoId === parsedTarget.repoId && runtimePathsEqual(parsedCandidate.worktreePath, parsedTarget.worktreePath) : candidate === worktree.id } : (candidate: string | undefined): boolean => candidate === worktree.id const ownsHost = (ptyId: string, connectionId?: string | null): boolean => { if (options.resolvedRuntimeEnvironmentId !== undefined) { return ptyId.startsWith( `remote:${encodeURIComponent(options.resolvedRuntimeEnvironmentId)}@@` ) } return ( options.resolvedConnectionId === undefined || connectionId === options.resolvedConnectionId ) } const ptyIds = new Set() for (const leaf of this.leaves.values()) { if ( ownsWorktree(leaf.worktreeId) && leaf.ptyId && ownsHost(leaf.ptyId, this.ptysById.get(leaf.ptyId)?.connectionId) ) { ptyIds.add(leaf.ptyId) } } for (const pty of this.ptysById.values()) { if (ownsWorktree(pty.worktreeId) && pty.connected && ownsHost(pty.ptyId, pty.connectionId)) { ptyIds.add(pty.ptyId) } } let stopped = 0 for (const ptyId of ptyIds) { if (options.deadline !== undefined && Date.now() >= options.deadline) { break } const stop = (): boolean | Promise => { if (options.deadline !== undefined && Date.now() >= options.deadline) { return false } if (options.stopPty) { // Why: destructive worktree cleanup must not let its cross-surface // dedupe treat fire-and-forget controller.kill as physical exit. // Why: the RPC deadline makes shutdown/list RPCs settle before the sweep // deadline so a wedged daemon yields the accurate stop failure; no deadline // (non-destructive) keeps the provider default RPC timeout. if (options.deadline !== undefined) { return ( this.ptyController?.stopAndWait?.(ptyId, { deadlineMs: teardownRpcDeadline(options.deadline) }) ?? false ) } return this.ptyController?.stopAndWait?.(ptyId) ?? false } return Boolean(this.ptyController?.kill(ptyId)) } const stopResult = options.stopPty ? await options.stopPty(ptyId, stop) : { stopped: stop(), owner: true } if (stopResult.owner && stopResult.stopped) { stopped += 1 } } return { stopped } } async sleepTerminalsForWorktree( worktreeSelector: string ): Promise { const worktree = await this.resolveWorktreeSelector(worktreeSelector) const existing = this.terminalSleepByWorktreeId.get(worktree.id) if (existing) { return await existing } const sleeping = this.sleepResolvedWorktreeTerminals(worktree) this.terminalSleepByWorktreeId.set(worktree.id, sleeping) try { return await sleeping } finally { if (this.terminalSleepByWorktreeId.get(worktree.id) === sleeping) { this.terminalSleepByWorktreeId.delete(worktree.id) } } } async acquireWorktreeTerminalSpawn(worktreeId?: string): Promise<() => void> { if (!worktreeId) { return () => {} } const release = await this.acquireWorktreeTerminalMutation(worktreeId) const key = runtimeWorktreeIdentityKey(worktreeId) const sleepState = this.terminalSleepStateByWorktreeId.get(key) if (sleepState?.phase === 'sleeping' || sleepState?.phase === 'partial') { this.terminalSleepStateByWorktreeId.delete(key) this.emitClientEvent({ type: 'worktreeTerminalSleepState', worktreeId: sleepState.worktreeId, generation: sleepState.generation, phase: 'woken', ptyIds: sleepState.ptyIds, terminalHandles: sleepState.terminalHandles }) } return release } private async acquireWorktreeTerminalMutation( worktreeId: string, deadline?: number ): Promise<() => void> { const key = runtimeWorktreeIdentityKey(worktreeId) const previous = this.terminalMutationTailByWorktreeId.get(key) ?? Promise.resolve() let releaseCurrent = (): void => {} const current = new Promise((resolve) => { releaseCurrent = resolve }) const tail = previous.catch(() => {}).then(() => current) this.terminalMutationTailByWorktreeId.set(key, tail) try { await waitForWorktreeTerminalMutation( previous.catch(() => {}), deadline ) } catch (error) { // Why: resolve this abandoned queue node now so it can never acquire later and stop a terminal after the caller timed out. releaseCurrent() void tail.finally(() => { if (this.terminalMutationTailByWorktreeId.get(key) === tail) { this.terminalMutationTailByWorktreeId.delete(key) } }) throw error } let released = false return () => { if (released) { return } released = true releaseCurrent() void tail.finally(() => { if (this.terminalMutationTailByWorktreeId.get(key) === tail) { this.terminalMutationTailByWorktreeId.delete(key) } }) } } private async sleepResolvedWorktreeTerminals( worktree: ResolvedWorktree ): Promise { const sleepDeadline = Date.now() + WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS const releaseMutation = await this.acquireWorktreeTerminalMutation(worktree.id, sleepDeadline) const key = runtimeWorktreeIdentityKey(worktree.id) const existingSleepState = this.terminalSleepStateByWorktreeId.get(key) if (existingSleepState?.phase === 'sleeping') { try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], worktree ) const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( resolvedWorktrees, worktree.id, sleepDeadline ) if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } if (this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness).size === 0) { releaseMutation() return { stopped: 0, stoppedPtyIds: [], livePtyIds: [], postStopVerified: true } } this.emitClientEvent({ type: 'worktreeTerminalSleepState', worktreeId: existingSleepState.worktreeId, generation: existingSleepState.generation, phase: 'woken', ptyIds: existingSleepState.ptyIds, terminalHandles: existingSleepState.terminalHandles }) this.terminalSleepStateByWorktreeId.delete(key) } catch (error) { releaseMutation() throw error } } const priorPartialState = existingSleepState?.phase === 'partial' ? existingSleepState : null const committedPtyIds = new Set(priorPartialState?.ptyIds ?? []) const terminalHandlesByPtyId = { ...priorPartialState?.terminalHandlesByPtyId } const pendingPtyIds = new Set() let generation = 0 let fullyCommitted = false let releaseReversibleRendererStops = (): void => {} try { const resolvedWorktrees = includeTargetResolvedWorktree( [...(await this.getResolvedWorktreeMap()).values()], worktree ) const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( resolvedWorktrees, worktree.id, sleepDeadline ) if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) generation = ++this.terminalSleepGeneration for (const ptyId of livePtyIds) { pendingPtyIds.add(ptyId) terminalHandlesByPtyId[ptyId] = this.getTerminalHandlesForPtyId(ptyId) } const liveTerminalHandles = this.getRecordedTerminalSleepHandles( livePtyIds, terminalHandlesByPtyId ) this.terminalSleepStateByWorktreeId.set(key, { worktreeId: worktree.id, generation, phase: 'stopping', ptyIds: [...committedPtyIds].sort(), terminalHandles: this.getRecordedTerminalSleepHandles( committedPtyIds, terminalHandlesByPtyId ), terminalHandlesByPtyId }) this.emitClientEvent({ type: 'worktreeTerminalSleepState', worktreeId: worktree.id, generation, phase: 'started', ptyIds: [...livePtyIds].sort(), terminalHandles: liveTerminalHandles }) if (committedPtyIds.size > 0) { this.emitClientEvent({ type: 'worktreeTerminalSleepState', worktreeId: worktree.id, generation, phase: 'committed', ptyIds: [...committedPtyIds].sort(), terminalHandles: this.getRecordedTerminalSleepHandles( committedPtyIds, terminalHandlesByPtyId ) }) } if (livePtyIds.size === 0) { const terminalHandles = this.getRecordedTerminalSleepHandles( committedPtyIds, terminalHandlesByPtyId ) this.terminalSleepStateByWorktreeId.set(key, { worktreeId: worktree.id, generation, phase: 'sleeping', ptyIds: [...committedPtyIds].sort(), terminalHandles, terminalHandlesByPtyId }) fullyCommitted = true return { stopped: 0, stoppedPtyIds: [], livePtyIds: [], postStopVerified: true } } const ptyController = this.ptyController if (!ptyController?.stopAndWait) { throw new Error('terminal_worktree_sleep_unavailable') } const stopAndWait = ptyController.stopAndWait.bind(ptyController) const orderedLivePtyIds = [...livePtyIds].sort() releaseReversibleRendererStops = ptyController.markReversibleStops?.(orderedLivePtyIds) ?? (() => {}) const stopResults = await Promise.allSettled( orderedLivePtyIds.map(async (ptyId) => ({ ptyId, stopped: await stopAndWait(ptyId, { keepHistory: true, deadlineMs: teardownRpcDeadline(sleepDeadline) }) })) ) const successfulStopPtyIds = orderedLivePtyIds.filter((_, index) => { const result = stopResults[index] return result?.status === 'fulfilled' && result.value.stopped }) const failedStopIndex = stopResults.findIndex((result) => result.status === 'rejected' ? true : !result.value.stopped ) const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController( resolvedWorktrees, worktree.id, sleepDeadline ) if (!postStopLiveness) { this.commitWorktreeTerminalSleepPtys({ worktreeId: worktree.id, generation, ptyIds: successfulStopPtyIds, pendingPtyIds, committedPtyIds, terminalHandlesByPtyId }) if (failedStopIndex >= 0) { const failedStop = stopResults[failedStopIndex] throw Object.assign(new Error('terminal_worktree_sleep_failed'), { ptyId: orderedLivePtyIds[failedStopIndex], ...(failedStop.status === 'rejected' ? { cause: failedStop.reason } : {}) }) } return { stopped: successfulStopPtyIds.length, stoppedPtyIds: successfulStopPtyIds, livePtyIds: [...livePtyIds].sort(), postStopVerified: false, postStopFailure: 'terminal_liveness_unavailable' } } const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) const provenStoppedPtyIds = orderedLivePtyIds.filter( (ptyId) => !remainingLivePtyIds.has(ptyId) ) this.commitWorktreeTerminalSleepPtys({ worktreeId: worktree.id, generation, ptyIds: provenStoppedPtyIds, pendingPtyIds, committedPtyIds, terminalHandlesByPtyId }) if (failedStopIndex >= 0 && remainingLivePtyIds.size > 0) { const failedStop = stopResults[failedStopIndex] console.error('[runtime] worktree terminal sleep physical stop failed', { worktreeId: worktree.id, ptyId: orderedLivePtyIds[failedStopIndex], cause: failedStop.status === 'rejected' ? failedStop.reason : 'stop_not_acknowledged' }) throw Object.assign(new Error('terminal_worktree_sleep_failed'), { ptyId: orderedLivePtyIds[failedStopIndex], remainingLivePtyIds: [...remainingLivePtyIds].sort(), ...(failedStop.status === 'rejected' ? { cause: failedStop.reason } : {}) }) } if (remainingLivePtyIds.size > 0) { return { stopped: successfulStopPtyIds.length, stoppedPtyIds: successfulStopPtyIds, livePtyIds: [...livePtyIds].sort(), postStopVerified: false, postStopFailure: 'terminal_worktree_sleep_still_live', remainingLivePtyIds: [...remainingLivePtyIds].sort() } } const terminalHandles = this.getRecordedTerminalSleepHandles( committedPtyIds, terminalHandlesByPtyId ) this.terminalSleepStateByWorktreeId.set(key, { worktreeId: worktree.id, generation, phase: 'sleeping', ptyIds: [...committedPtyIds].sort(), terminalHandles, terminalHandlesByPtyId }) fullyCommitted = true return { stopped: provenStoppedPtyIds.length, stoppedPtyIds: provenStoppedPtyIds, livePtyIds: [...livePtyIds].sort(), postStopVerified: true } } finally { releaseReversibleRendererStops() if (!fullyCommitted && generation > 0) { const cancelledPtyIds = [...pendingPtyIds].sort() if (cancelledPtyIds.length > 0) { this.emitClientEvent({ type: 'worktreeTerminalSleepState', worktreeId: worktree.id, generation, phase: 'cancelled', ptyIds: cancelledPtyIds, terminalHandles: this.getRecordedTerminalSleepHandles( cancelledPtyIds, terminalHandlesByPtyId ) }) } if (committedPtyIds.size > 0) { const terminalHandles = this.getRecordedTerminalSleepHandles( committedPtyIds, terminalHandlesByPtyId ) this.terminalSleepStateByWorktreeId.set(key, { worktreeId: worktree.id, generation, phase: 'partial', ptyIds: [...committedPtyIds].sort(), terminalHandles, terminalHandlesByPtyId }) } else { this.terminalSleepStateByWorktreeId.delete(key) } } releaseMutation() } } async stopExactTerminalsForWorktree( worktreeSelector: string, expectedPtyIds: readonly string[], opts: { keepHistory?: boolean; targetOnly?: boolean } = {} ): Promise<{ stopped: number stoppedPtyIds: string[] livePtyIds: string[] postStopVerified: boolean postStopFailure?: string remainingLivePtyIds?: string[] }> { // Why: exact stop hibernates one known pane; worktree sleep discovers its complete host-owned set separately. const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) const expected = new Set(expectedPtyIds.filter((ptyId) => ptyId.length > 0)) if (expected.size !== 1) { throw new Error('terminal_exact_stop_requires_single_pty') } const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()] const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) if (!refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } const livePtyIds = this.getLivePtyIdsForWorktree(worktree.id, refreshedPtyLiveness) const targetOnly = opts.targetOnly === true const expectedIsLive = [...expected].every((ptyId) => livePtyIds.has(ptyId)) if (targetOnly ? !expectedIsLive : !setsEqual(livePtyIds, expected)) { const error = Object.assign(new Error('terminal_stop_pty_set_mismatch'), { livePtyIds: [...livePtyIds].sort(), expectedPtyIds: [...expected].sort() }) throw error } if (!this.ptyController?.stopAndWait) { throw new Error('terminal_exact_stop_unavailable') } const stoppedPtyIds: string[] = [] for (const ptyId of [...expected].sort()) { if (opts.keepHistory) { this.intentionalHandlelessPtyStops.set( ptyId, this.ptysById.get(ptyId)?.incarnationId ?? null ) } try { if (!(await this.ptyController.stopAndWait(ptyId, { keepHistory: opts.keepHistory }))) { throw Object.assign(new Error('terminal_exact_stop_failed'), { ptyId }) } } finally { this.intentionalHandlelessPtyStops.delete(ptyId) } stoppedPtyIds.push(ptyId) } const postStopLiveness = await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees) if (!postStopLiveness) { return { stopped: stoppedPtyIds.length, stoppedPtyIds, livePtyIds: [...livePtyIds].sort(), postStopVerified: false, postStopFailure: 'terminal_liveness_unavailable' } } const remainingLivePtyIds = this.getLivePtyIdsForWorktree(worktree.id, postStopLiveness) const stoppedTargetsStillLive = [...expected].filter((ptyId) => remainingLivePtyIds.has(ptyId)) if (targetOnly ? stoppedTargetsStillLive.length > 0 : remainingLivePtyIds.size > 0) { return { stopped: stoppedPtyIds.length, stoppedPtyIds, livePtyIds: [...livePtyIds].sort(), postStopVerified: false, postStopFailure: 'terminal_exact_stop_still_live', remainingLivePtyIds: [...remainingLivePtyIds].sort() } } return { stopped: stoppedPtyIds.length, stoppedPtyIds, livePtyIds: [...livePtyIds].sort(), postStopVerified: true, ...(targetOnly && remainingLivePtyIds.size > 0 ? { remainingLivePtyIds: [...remainingLivePtyIds].sort() } : {}) } } private getLivePtyIdsForWorktree( worktreeId: string, freshPtyIds?: ReadonlySet ): Set { const ptyIds = new Set() for (const leaf of this.leaves.values()) { if ( runtimeWorktreeIdsEqual(leaf.worktreeId, worktreeId) && leaf.connected && leaf.ptyId && (!freshPtyIds || freshPtyIds.has(leaf.ptyId)) ) { ptyIds.add(leaf.ptyId) } } for (const pty of this.ptysById.values()) { if ( runtimeWorktreeIdsEqual(pty.worktreeId, worktreeId) && pty.connected && (!freshPtyIds || freshPtyIds.has(pty.ptyId)) ) { ptyIds.add(pty.ptyId) } } return ptyIds } private getTerminalHandlesForPtyId(ptyId: string): string[] { const handles = new Set( this.getLeavesForPty(ptyId) .filter((candidate) => candidate.connected) .map((leaf) => this.issueHandle(leaf)) ) const runtimeHandle = this.handleByPtyId.get(ptyId) if (runtimeHandle) { handles.add(runtimeHandle) } const pty = this.getOrCreatePtyWorktreeRecord(ptyId) if (!pty) { throw Object.assign(new Error('terminal_worktree_sleep_handle_unavailable'), { ptyId }) } if (handles.size === 0) { handles.add(this.issuePtyHandle(pty)) } return [...handles].sort() } private getRecordedTerminalSleepHandles( ptyIds: Iterable, terminalHandlesByPtyId: Readonly> ): string[] { return [...new Set([...ptyIds].flatMap((ptyId) => terminalHandlesByPtyId[ptyId] ?? []))].sort() } private commitWorktreeTerminalSleepPtys(args: { worktreeId: string generation: number ptyIds: readonly string[] pendingPtyIds: Set committedPtyIds: Set terminalHandlesByPtyId: Readonly> }): void { const newlyCommittedPtyIds = [...new Set(args.ptyIds)] .filter((ptyId) => !args.committedPtyIds.has(ptyId)) .sort() for (const ptyId of newlyCommittedPtyIds) { args.pendingPtyIds.delete(ptyId) args.committedPtyIds.add(ptyId) } if (newlyCommittedPtyIds.length === 0) { return } this.emitClientEvent({ type: 'worktreeTerminalSleepState', worktreeId: args.worktreeId, generation: args.generation, phase: 'committed', ptyIds: newlyCommittedPtyIds, terminalHandles: this.getRecordedTerminalSleepHandles( newlyCommittedPtyIds, args.terminalHandlesByPtyId ) }) } async hasTerminalsForWorktree(worktreeSelector: string): Promise { const graphEpoch = this.captureReadyGraphEpoch() const worktree = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) for (const leaf of this.leaves.values()) { if (leaf.worktreeId === worktree.id && leaf.ptyId) { return true } } for (const pty of this.ptysById.values()) { if (pty.worktreeId === worktree.id && pty.connected) { return true } } return false } markRendererReloading(windowId: number): void { if (windowId !== this.authoritativeWindowId) { return } if (this.graphStatus !== 'ready') { return } // Why: a renderer reload tears down the live graph, so live handles must go stale immediately, not be reused against the rebuild. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() // Why: handleByPtyId (pre-allocated CLI handles) survives reloads so CLI agents keep control; adoptPreAllocatedHandle re-links on the new graph. this.rejectAllWaiters('terminal_handle_stale') this.refreshWritableFlags() } markGraphReady(windowId: number): void { if (windowId !== this.authoritativeWindowId) { return } this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } markGraphUnavailable(windowId: number): void { if (windowId !== this.authoritativeWindowId) { return } // Why: once the authoritative renderer graph disappears, fail closed for live-terminal ops instead of guessing from old state. if (this.graphStatus !== 'unavailable') { this.rendererGraphEpoch += 1 } this.graphStatus = 'unavailable' this.setTerminalSideEffectConsumerAvailable(false) this.authoritativeWindowId = null this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() this.leaves.clear() this.leavesByPtyId.clear() this.handles.clear() this.handleByLeafKey.clear() // Why: pre-allocated CLI handles must survive graph unavailability so they can be re-adopted on reconnect. this.rejectAllWaiters('terminal_handle_stale') } private assertGraphReady(): void { if (this.graphStatus !== 'ready') { throw new Error('runtime_unavailable') } } private captureReadyGraphEpoch(): number { this.assertGraphReady() return this.rendererGraphEpoch } private assertStableReadyGraph(expectedGraphEpoch: number): void { if (this.graphStatus !== 'ready' || this.rendererGraphEpoch !== expectedGraphEpoch) { throw new Error('runtime_unavailable') } } private resolveFolderWorkspaceConnectionId(workspace: FolderWorkspace): string | null { const repos = this.store?.getRepos() ?? [] const projectGroups = this.store?.getProjectGroups?.() ?? [] const connection = inferFolderWorkspacePathConnection({ folderPath: workspace.folderPath, projectGroupId: workspace.projectGroupId, connectionId: workspace.connectionId ?? null, projectGroups, repos }) if (connection.kind === 'ambiguous') { // Why: a PTY spawns on one runtime target; mixed child-repo connections need an explicit V2 routing decision. throw new Error('folder_workspace_connection_ambiguous') } return connection.kind === 'ssh' ? connection.connectionId : null } private async resolveFolderWorkspaceLaunchScope( selector: string ): Promise { const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = parseWorkspaceKey(workspaceSelector) if (parsed?.type !== 'folder') { return null } const workspace = this.store ?.getFolderWorkspaces?.() .find((entry) => entry.id === parsed.folderWorkspaceId) if (!workspace) { throw new Error('selector_not_found') } if (!this.store) { throw new Error('runtime_unavailable') } const status = await getFolderWorkspacePathStatus( this.store, { scope: 'folder-workspace', folderWorkspaceId: workspace.id }, { getSshFilesystemProvider } ) assertFolderWorkspacePathUsable(status) return { id: folderWorkspaceKey(workspace.id), path: workspace.folderPath, connectionId: this.resolveFolderWorkspaceConnectionId(workspace), repo: null, folderWorkspace: workspace } } private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { const worktree = folderWorkspaceToWorktree(folderWorkspace) return { ...worktree, parentWorktreeId: null, childWorktreeIds: [], lineage: null, git: { path: worktree.path, head: worktree.head, branch: worktree.branch, isBare: worktree.isBare, isMainWorktree: worktree.isMainWorktree } } } private resolveWorkspaceTerminalStartupCwd( workspace: Pick, requestedCwd?: string | null ): string | undefined { return resolveTerminalStartupCwd(workspace.path, requestedCwd) } private async resolveTerminalWorkspaceLaunchScope( selector: string ): Promise { const floatingTerminalSelector = selector === FLOATING_TERMINAL_WORKTREE_ID || selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` if (floatingTerminalSelector) { // Why: the floating sentinel is terminal-only — no backing repo/worktree record for other workspace APIs. return { id: FLOATING_TERMINAL_WORKTREE_ID, path: homedir(), connectionId: null, repo: null, folderWorkspace: null } } const folderScope = await this.resolveFolderWorkspaceLaunchScope(selector) if (folderScope) { return folderScope } const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = parseWorkspaceKey(workspaceSelector) const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) ?? null return { id: worktree.id, path: worktree.path, connectionId: repo?.connectionId ?? null, repo, folderWorkspace: null } } private buildTerminalWorkspaceEnv( scope: TerminalWorkspaceLaunchScope, baseEnv: Record, paneKey: string, tabId: string, agentTeamsEnv?: Record ): Record { const cleanBaseEnv = { ...baseEnv } for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { delete cleanBaseEnv[key] } const env = { ...cleanBaseEnv, ...agentTeamsEnv, ...this.buildAgentHookPtyEnv?.(), ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: scope.id } if (!scope.folderWorkspace) { return env } return { ...env, ORCA_WORKSPACE_ID: scope.id, ORCA_PROJECT_GROUP_ID: scope.folderWorkspace.projectGroupId, ORCA_WORKSPACE_ROOT: scope.folderWorkspace.folderPath } } private getValidatedExplicitWorktreeIdSelector(selector: string | undefined): string | null { const worktreeId = getExplicitWorktreeIdSelector(selector) if ( worktreeId && !worktreeId.includes(WORKTREE_ID_SEPARATOR) && this.store?.getRepo(worktreeId) ) { // Why: a registered repo id is a known-invalid worktree id; reject early before fast paths or Git/SSH scans hide the mistake. throw new WorktreeIdRequiresFullPathError() } return worktreeId } private async resolveWorktreeSelector(selector: string): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(selector) const worktrees = await this.listResolvedWorktrees() let candidates: ResolvedWorktree[] if (selector === 'active') { throw new Error('selector_not_found') } if (selector.startsWith('id:')) { const worktreeId = explicitWorktreeId ?? selector.slice(3) candidates = worktrees.filter((worktree) => worktree.id === worktreeId) if (candidates.length === 0) { const parsed = splitWorktreeIdForFilesystem(worktreeId) const repo = parsed ? this.store?.getRepo(parsed.repoId) : null const fallback = repo?.connectionId && this.store?.getWorktreeMeta(worktreeId) ? this.buildResolvedWorktreeFromId(worktreeId) : null if (fallback !== null) { candidates = [fallback] } } } else if (selector.startsWith('path:')) { candidates = worktrees.filter((worktree) => runtimePathsEqual(worktree.path, selector.slice(5)) ) if (candidates.length > 1) { // Why: the same physical path can appear under multiple repo IDs; a path selector is exact, so take the first row over a dup-registration ambiguity. candidates = [candidates[0]] } } else if (selector.startsWith('branch:')) { const branchSelector = selector.slice(7) candidates = worktrees.filter((worktree) => branchSelectorMatches(worktree.branch, branchSelector) ) } else if (selector.startsWith('name:')) { // Keep display-name matching exact so duplicate names hit the same ambiguity path as other selectors. candidates = worktrees.filter((worktree) => worktree.displayName === selector.slice(5)) } else if (selector.startsWith('issue:')) { candidates = worktrees.filter( (worktree) => worktree.linkedIssue !== null && String(worktree.linkedIssue) === selector.slice(6) ) } else { candidates = worktrees.filter( (worktree) => worktree.id === selector || runtimePathsEqual(worktree.path, selector) || branchSelectorMatches(worktree.branch, selector) ) } if (candidates.length === 1) { return candidates[0] } if (candidates.length > 1) { throw new Error('selector_ambiguous') } throw new Error('selector_not_found') } private async resolveWorkspaceParentSelector(selector: string): Promise { const rawSelector = selector.startsWith('id:') ? selector.slice('id:'.length) : selector const parsed = parseWorkspaceKey(rawSelector) if (parsed?.type === 'folder') { const folderWorkspace = this.store ?.getFolderWorkspaces?.() .find((workspace) => workspace.id === parsed.folderWorkspaceId) if (!folderWorkspace) { throw new Error('selector_not_found') } return { type: 'folder', workspaceKey: folderWorkspaceKey(folderWorkspace.id), folderWorkspace, instanceId: null } } const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector const worktree = await this.resolveWorktreeSelector(worktreeSelector) return { type: 'worktree', workspaceKey: worktreeWorkspaceKey(worktree.id), worktree, instanceId: worktree.instanceId ?? null } } private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void { const childWorktreeId = child.id const parentWorktreeId = parent.id if (childWorktreeId === parentWorktreeId) { throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.') } if (!sharesResolvedWorktreeLineageBoundary(child, parent)) { throw new RuntimeLineageError( 'LINEAGE_PARENT_CONTEXT_CONFLICT', 'Parent worktree must belong to the same repository, execution host, and project.' ) } const instanceByWorktreeId = new Map( this.resolvedWorktreeCache?.worktrees.map((worktree) => [ worktree.id, worktree.instanceId ]) ?? [ [child.id, child.instanceId], [parent.id, parent.instanceId] ] ) let cursor: string | undefined = parentWorktreeId const visited = new Set([childWorktreeId]) while (cursor) { if (visited.has(cursor)) { throw new RuntimeLineageError( 'LINEAGE_PARENT_CYCLE', 'Parent selector would create a lineage cycle.' ) } visited.add(cursor) const lineage = this.store?.getWorktreeLineage?.(cursor) if (!lineage) { break } const cursorInstanceId = instanceByWorktreeId.get(cursor) const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId) if ( cursorInstanceId !== lineage.worktreeInstanceId || parentInstanceId !== lineage.parentWorktreeInstanceId ) { break } cursor = lineage.parentWorktreeId } } private async resolveLineageForWorktreeCreate( input?: WorktreeLineageInput ): Promise { const parentSelectorNextSteps = [ 'Pass a valid --parent-worktree selector such as folder:, worktree:, id:::, branch:, issue:, path:, or active/current.', 'Retry with --no-parent to create without lineage.' ] const parentSelectorNotFoundMessage = (err: unknown): string => err instanceof WorktreeIdRequiresFullPathError ? err.message : 'Parent selector was not found.' if (!input) { return { kind: 'none', warnings: [] } } if (input.noParent === true && (input.parentWorkspace || input.parentWorktree)) { throw new RuntimeLineageError( 'LINEAGE_PARENT_CONTEXT_CONFLICT', 'Choose either one parent selector or --no-parent.' ) } if (input.parentWorkspace && input.parentWorktree) { throw new RuntimeLineageError( 'LINEAGE_PARENT_CONTEXT_CONFLICT', 'Choose either one parent selector or --no-parent.' ) } if (input.noParent === true) { return { kind: 'none', warnings: [] } } if (input.parentWorkspace) { try { return { kind: 'lineage', parent: await this.resolveWorkspaceParentSelector(input.parentWorkspace), origin: 'cli', capture: { source: 'explicit-cli-flag', confidence: 'explicit' } } } catch (err) { throw new RuntimeLineageError( 'LINEAGE_PARENT_NOT_FOUND', parentSelectorNotFoundMessage(err), { nextSteps: parentSelectorNextSteps } ) } } if (input.parentWorktree) { try { const parent = await this.resolveWorktreeSelector(input.parentWorktree) return { kind: 'lineage', parent: { type: 'worktree', workspaceKey: worktreeWorkspaceKey(parent.id), worktree: parent, instanceId: parent.instanceId ?? null }, origin: 'cli', capture: { source: 'explicit-cli-flag', confidence: 'explicit' } } } catch (err) { throw new RuntimeLineageError( 'LINEAGE_PARENT_NOT_FOUND', parentSelectorNotFoundMessage(err), { nextSteps: parentSelectorNextSteps } ) } } const warnings: WorktreeLineageWarning[] = [] const candidates: WorktreeLineageCandidate[] = [] let cwdCandidate: WorktreeLineageCandidate | null = null let terminalContextResolved = false if (input.envParentWorkspace) { try { candidates.push({ source: 'env-workspace', parent: await this.resolveWorkspaceParentSelector(input.envParentWorkspace) }) } catch { warnings.push({ code: 'LINEAGE_PARENT_CONTEXT_MISSING', message: 'Worktree created, but Orca could not validate the environment parent context.', details: { envParentWorkspace: input.envParentWorkspace } }) } } if (input.orchestrationContext?.parentWorktreeId) { try { const parent = await this.resolveWorktreeSelector( `id:${input.orchestrationContext.parentWorktreeId}` ) candidates.push({ source: 'orchestration-context', parent: { type: 'worktree', workspaceKey: worktreeWorkspaceKey(parent.id), worktree: parent, instanceId: parent.instanceId ?? null } }) } catch { // Keep creation recoverable; the warning below covers missing inferred context. } } const commentTaskId = extractOrchestrationTaskId(input.comment) if (commentTaskId) { const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId) if (candidate) { candidates.push(candidate) } } if (input.callerTerminalHandle) { try { const terminal = await this.showTerminal(input.callerTerminalHandle) const terminalParent = await this.resolveWorkspaceParentSelector( `id:${terminal.worktreeId}` ) const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal( input.callerTerminalHandle ) const activeRun = this._orchestrationDb?.getActiveCoordinatorRun() if (activeDispatch) { candidates.push({ source: 'orchestration-context', parent: terminalParent, taskId: activeDispatch.task_id, ...(activeRun ? { orchestrationRunId: activeRun.id, coordinatorHandle: activeRun.coordinator_handle } : {}) }) } else { candidates.push({ source: 'terminal-context', parent: terminalParent }) } terminalContextResolved = true } catch { // Why: a stale terminal handle (reload/SSH reconnect) shouldn't drop lineage; keep resolving other inferred candidates. warnings.push({ code: 'LINEAGE_PARENT_CONTEXT_MISSING', message: 'Worktree created, but Orca could not validate the caller terminal as a parent context.', details: { callerTerminalHandle: input.callerTerminalHandle } }) } } if (input.cwdParentWorktree) { try { cwdCandidate = { source: 'cwd-context', parent: await this.resolveWorkspaceParentSelector(input.cwdParentWorktree) } } catch { warnings.push({ code: 'LINEAGE_PARENT_CONTEXT_MISSING', message: 'Worktree created, but Orca could not validate the current directory as a parent context.', details: { cwdParentWorktree: input.cwdParentWorktree } }) } } if (candidates.length === 0 && cwdCandidate) { candidates.push(cwdCandidate) } if (candidates.length === 0) { return { kind: 'none', warnings } } const [first] = candidates const conflict = candidates.find( (candidate) => candidate.parent.workspaceKey !== first.parent.workspaceKey ) if (conflict) { return { kind: 'none', warnings: [ { code: 'LINEAGE_PARENT_CONTEXT_CONFLICT', message: 'Worktree created, but Orca could not prove which parent context caused it.', details: { terminalParentWorkspaceKey: candidates.find((c) => c.source === 'terminal-context') ?.parent.workspaceKey, envParentWorkspaceKey: candidates.find((c) => c.source === 'env-workspace')?.parent .workspaceKey, orchestrationParentWorkspaceKey: candidates.find( (c) => c.source === 'orchestration-context' )?.parent.workspaceKey } } ] } } const preferred = candidates.find((candidate) => candidate.source === 'env-workspace') ?? candidates.find((candidate) => candidate.source === 'orchestration-context') ?? first return { kind: 'lineage', parent: preferred.parent, origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli', capture: { source: preferred.source, confidence: 'inferred' }, ...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId) ? { orchestrationRunId: preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId } : {}), ...((preferred.taskId ?? input.orchestrationContext?.taskId) ? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId } : {}), ...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle) ? { coordinatorHandle: preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle } : {}), ...(terminalContextResolved && input.callerTerminalHandle ? { createdByTerminalHandle: input.callerTerminalHandle } : {}) } } private async resolveLineageCandidateForTaskId( taskId: string ): Promise { const db = this.getOrchestrationDbIfAvailable() const dispatch = db?.getDispatchContext(taskId) // Why: agent-created tasks may never be dispatched, but the creating terminal still identifies the parent workspace. const parentHandle = dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle if (!parentHandle) { return null } try { const terminal = await this.showTerminal(parentHandle) const parent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) return { source: 'orchestration-context', parent: { type: 'worktree', workspaceKey: worktreeWorkspaceKey(parent.id), worktree: parent, instanceId: parent.instanceId ?? null }, taskId } } catch { return null } } private getOrchestrationDbIfAvailable(): OrchestrationDb | null { try { return this._orchestrationDb ?? this.getOrchestrationDb() } catch { return this._orchestrationDb } } async hydrateInferredWorktreeLineage(): Promise { const store = this.store if ( !store || typeof store.getWorktreeLineage !== 'function' || typeof store.setWorktreeLineage !== 'function' ) { return } const worktrees = await this.listResolvedWorktrees() for (const worktree of worktrees) { if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) { continue } const taskId = extractOrchestrationTaskId(worktree.comment) if (!taskId) { continue } const candidate = await this.resolveLineageCandidateForTaskId(taskId) if ( !candidate?.parent.instanceId || candidate.parent.type !== 'worktree' || candidate.parent.worktree.id === worktree.id ) { continue } try { this.validateLineageParent(worktree, candidate.parent.worktree) } catch { continue } store.setWorktreeLineage(worktree.id, { worktreeId: worktree.id, worktreeInstanceId: worktree.instanceId, parentWorktreeId: candidate.parent.worktree.id, parentWorktreeInstanceId: candidate.parent.instanceId, origin: 'orchestration', capture: { source: 'orchestration-context', confidence: 'inferred' }, taskId, createdAt: Date.now() }) } } async listWorktreeLineage(): Promise> { await this.hydrateInferredWorktreeLineage() return this.store?.getAllWorktreeLineage?.() ?? {} } async listWorkspaceLineage(): Promise> { await this.hydrateInferredWorktreeLineage() return this.store?.getAllWorkspaceLineage?.() ?? {} } // Why: one selector grammar, so connection-scoped resolution can narrow the same // candidate set instead of reimplementing (and diverging from) the matching rules. private selectReposBySelector(selector: string): Repo[] { const repos = this.store?.getRepos() ?? [] if (selector.startsWith('id:')) { return repos.filter((repo) => repo.id === selector.slice(3)) } if (selector.startsWith('path:')) { return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) } if (selector.startsWith('name:')) { return repos.filter((repo) => repo.displayName === selector.slice(5)) } return repos.filter( (repo) => repo.id === selector || runtimePathsEqual(repo.path, selector) || repo.displayName === selector ) } private async resolveRepoSelector(selector: string): Promise { if (!this.store) { throw new Error('repo_not_found') } const candidates = this.selectReposBySelector(selector) if (candidates.length === 1) { return candidates[0] } if (candidates.length > 1) { throw new Error('selector_ambiguous') } throw new Error('repo_not_found') } private requireStore(): Store { if (!this.store) { throw new Error('runtime_unavailable') } return this.store as unknown as Store } private buildResolvedWorktreeFromId(worktreeId: string): ResolvedWorktree | null { const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!parsed?.repoId || !parsed.worktreePath) { return null } const repo = this.store?.getRepos().find((entry) => entry.id === parsed.repoId) const git = { path: parsed.worktreePath, head: '', branch: '', isBare: false, isMainWorktree: repo ? areWorktreePathsEqual(parsed.worktreePath, repo.path) : false } const meta = this.store?.getWorktreeMeta(worktreeId) const merged = { ...mergeWorktree(parsed.repoId, git, meta, repo?.displayName), ...(repo ? { hostId: meta?.hostId ?? getRepoExecutionHostId(repo) } : {}) } return { ...merged, id: worktreeId, parentWorktreeId: null, childWorktreeIds: [], lineage: null, git, displayName: merged.displayName, comment: merged.comment } } private listKnownResolvedWorktreesForExplicitTarget( targetWorktreeId: string, targetWorktree: ResolvedWorktree | null ): ResolvedWorktree[] { if (!this.store || !targetWorktree) { return [] } const target = splitWorktreeIdForFilesystem(targetWorktreeId) if (!target?.repoId || !target.worktreePath) { return [] } const worktreeIds = new Set( Object.keys(this.store.getAllWorktreeMeta()).filter((worktreeId) => { const parsed = splitWorktreeIdForFilesystem(worktreeId) return ( parsed?.repoId === target.repoId && Boolean(parsed.worktreePath) && (isPathInsideOrEqual(target.worktreePath, parsed.worktreePath) || isPathInsideOrEqual(parsed.worktreePath, target.worktreePath)) ) }) ) worktreeIds.add(targetWorktreeId) const resolved: ResolvedWorktree[] = [] for (const worktreeId of worktreeIds) { const worktree = worktreeId === targetWorktreeId ? targetWorktree : this.buildResolvedWorktreeFromId(worktreeId) if (worktree) { resolved.push(worktree) } } return resolved } private async listResolvedWorktrees(): Promise { return (await this.listResolvedWorktreeSnapshot()).worktrees } private async listResolvedWorktreeSnapshot(): Promise { if (!this.store) { return { worktrees: [], platformByRepoId: new Map() } } const now = Date.now() if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) { return this.resolvedWorktreeCache } const generation = this.resolvedWorktreeGeneration if (this.resolvedWorktreeInFlight?.generation === generation) { return this.resolvedWorktreeInFlight.promise } const promise = this.computeResolvedWorktrees(generation) this.resolvedWorktreeInFlight = { generation, promise } try { return await promise } finally { if (this.resolvedWorktreeInFlight?.promise === promise) { this.resolvedWorktreeInFlight = null } } } private async computeResolvedWorktrees(generation: number): Promise { if (!this.store) { return { worktrees: [], platformByRepoId: new Map() } } const now = Date.now() const metaById = this.store.getAllWorktreeMeta() ?? {} const repos = this.store.getRepos() const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(this.requireStore(), repos) const platformByRepoId = new Map( repos.map((repo) => [ repo.id, getAgentLaunchPlatformForRepo(repo, projectRuntimeByRepoId.get(repo.id)) ]) ) const perRepoWorktrees = await Promise.all( repos.map(async (repo) => { if (isFolderRepo(repo)) { return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({ ...worktree, hostId: worktree.hostId ?? getRepoExecutionHostId(repo), parentWorktreeId: null, childWorktreeIds: [], lineage: null, git: { path: worktree.path, head: worktree.head, branch: worktree.branch, isBare: worktree.isBare, isMainWorktree: worktree.isMainWorktree }, displayName: worktree.displayName, comment: worktree.comment })) } // Why: mobile startup shares this path, so a slow repo scan degrades one repo's metadata instead of blocking all session loading. // Why the catch: `withTimeout` resolves its fallback on rejection too, so the rejection must be absorbed first for `null` to mean // "timed out" only. A stall never reached a verdict, so restore persisted rows instead of publishing a healthy-looking empty catalog; // a rejection is a real answer and keeps its shipped zero-row semantics. const scan: RuntimeWorktreeScanResult = (await withTimeout( this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId).catch( () => ({ ok: false, worktrees: [] }) satisfies RuntimeWorktreeScanResult ), RESOLVED_WORKTREE_REPO_TIMEOUT_MS, null )) ?? { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } const gitWorktrees = scan.worktrees if (scan.ok) { this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees) } return gitWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` // Why: lineage validation needs a durable instance ID even when the runtime sees a workspace before renderer discovery-stamp. const existingMeta = metaById[worktreeId] const meta = existingMeta && existingMeta.instanceId ? existingMeta : this.store?.setWorktreeMeta(worktreeId, {}) const merged = { ...mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), hostId: existingMeta?.hostId ?? meta?.hostId ?? getRepoExecutionHostId(repo) } return { ...merged, parentWorktreeId: null, childWorktreeIds: [], lineage: null, git: { path: gitWorktree.path, head: gitWorktree.head, branch: gitWorktree.branch, isBare: gitWorktree.isBare, isMainWorktree: gitWorktree.isMainWorktree }, displayName: merged.displayName, comment: merged.comment } }) }) ) const worktrees = projectResolvedWorktreeLineage( perRepoWorktrees.flat(), this.store?.getAllWorktreeLineage?.() ?? {} ) // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. if (generation === this.resolvedWorktreeGeneration) { this.resolvedWorktreeCache = { worktrees, platformByRepoId, expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS } } return { worktrees, platformByRepoId } } private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { const store = this.store if ( !store || typeof store.getAllWorktreeLineage !== 'function' || typeof store.removeWorktreeLineage !== 'function' ) { return } const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) const repoPrefix = `${repo.id}::` for (const childWorkspaceKey of Object.keys(store.getAllWorkspaceLineage?.() ?? {})) { const childScope = parseWorkspaceKey(childWorkspaceKey) if ( childScope?.type === 'worktree' && childScope.worktreeId.startsWith(repoPrefix) && !liveIds.has(childScope.worktreeId) ) { if (isWorkspaceKey(childWorkspaceKey)) { store.removeWorkspaceLineage?.(childWorkspaceKey) } } } for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) { if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) { // Why: once a scan proves the child gone, purge stale lineage so it can't survive into a same-path replacement checkout. store.removeWorktreeLineage(childId) store.removeWorkspaceLineage?.(worktreeWorkspaceKey(childId)) } if ( lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId) ) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { // Why: a missing parent path needs one fresh identity so same-path replacement checkouts can't validate old lineage. store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) } } } } private async listRepoWorktreesForResolution( repo: Repo, projectRuntimeByRepoId?: ReadonlyMap ): Promise { const now = Date.now() const generation = this.worktreeScanGenerations.get(repo.id) ?? 0 const projectRuntime = projectRuntimeByRepoId ? projectRuntimeByRepoId.get(repo.id) : !repo.connectionId ? resolveLocalProjectRuntimeForRepo(this.requireStore(), repo) : undefined const runtimeKey = projectRuntime ? projectRuntime.status === 'resolved' ? projectRuntime.runtime.cacheKey : projectRuntime.repair.cacheKey : repo.connectionId ? `ssh:${repo.connectionId}:${getSshGitProviderGeneration(repo.connectionId)}` : 'local:default' const cached = this.worktreeScanCache.get(repo.id) if ( cached?.generation === generation && cached.runtimeKey === runtimeKey && cached.expiresAt > now ) { return cached.result } const inFlight = this.worktreeScanInFlight.get(repo.id) if (inFlight?.generation === generation && inFlight.runtimeKey === runtimeKey) { return inFlight.promise } const promise = this.listRepoWorktreesForResolutionUncached(repo, projectRuntime) this.worktreeScanInFlight.set(repo.id, { generation, runtimeKey, promise }) try { const result = await promise if ( result.ok && generation === (this.worktreeScanGenerations.get(repo.id) ?? 0) && this.worktreeScanInFlight.get(repo.id)?.promise === promise ) { this.worktreeScanCache.set(repo.id, { generation, runtimeKey, result, expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo) }) } return result } finally { if (this.worktreeScanInFlight.get(repo.id)?.promise === promise) { this.worktreeScanInFlight.delete(repo.id) } } } private async listRepoWorktreesForResolutionUncached( repo: Repo, projectRuntime: ProjectExecutionRuntimeResolution | undefined ): Promise { if (!repo.connectionId) { return { ok: true, worktrees: await listRepoWorktrees( repo, getLocalProjectWorktreeGitOptionsForRuntime(repo, projectRuntime) ) } } const provider = getSshGitProvider(repo.connectionId) if (!provider) { return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } } try { return { ok: true, worktrees: await provider.listWorktrees(repo.path) } } catch { return { ok: false, worktrees: this.listStoredWorktreesForResolution(repo) } } } private listStoredWorktreesForResolution(repo: Repo): GitWorktreeInfo[] { const store = this.store if (!store) { return [] } const expectedHostId = getRepoExecutionHostId(repo) const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length const byWorktreeId = new Map() for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) { const parsed = splitWorktreeId(worktreeId) if (!parsed || parsed.repoId !== repo.id) { continue } // Why: one repo id can be registered on several execution hosts, so a degraded host must not republish another host's rows (same gate as worktrees.ts). if (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) { continue } // Why: keep persisted rows for any repo kind while its scan is unreachable or stalled, instead of zero rows (worktrees:list does the same for disconnected SSH). byWorktreeId.set(worktreeId, { path: parsed.worktreePath, head: '', branch: '', isBare: false, isMainWorktree: areWorktreePathsEqual(parsed.worktreePath, repo.path), ...(meta.sparseDirectories !== undefined || meta.sparseBaseRef !== undefined || meta.sparsePresetId !== undefined ? { isSparse: true } : {}) }) } return [...byWorktreeId.values()] } private async getResolvedWorktreeMap(): Promise> { return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) } private invalidateResolvedWorktreeCache(): void { this.resolvedWorktreeGeneration += 1 this.resolvedWorktreeCache = null } private invalidateWorktreeScanCacheForRepo(repoId: string): void { this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) this.worktreeScanCache.delete(repoId) this.worktreeScanInFlight.delete(repoId) } private invalidateSshWorktreeScanCacheInternal(targetId: string): void { const repos = this.store?.getRepos() ?? [] const affectedRepoIds = new Set( repos.filter((repo) => repo.connectionId === targetId).map((repo) => repo.id) ) for (const repoId of affectedRepoIds) { this.worktreeScanGenerations.set(repoId, (this.worktreeScanGenerations.get(repoId) ?? 0) + 1) this.worktreeScanCache.delete(repoId) this.worktreeScanInFlight.delete(repoId) } if (affectedRepoIds.size > 0) { this.resolvedWorktreeGeneration += 1 this.resolvedWorktreeCache = null } } /** Invalidate the worktree cache and tell the renderer to re-list after an out-of-band branch change so the new name surfaces immediately. */ notifyBranchRenamed(repoId: string): void { this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repoId) this.notifyWorktreesChanged(repoId) } /** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */ notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void { this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(repoId) this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId }) // Mirror notifyBranchRenamed so in-process onClientEvent listeners also see the rename. this.emitClientEvent({ type: 'worktreesChanged', repoId }) } notifyFolderWorkspaceChanged(): void { this.invalidateResolvedWorktreeCache() this.notifyReposChanged() } private recordPtyWorktree( ptyId: string, worktreeId: string, state: Partial< Pick< RuntimePtyWorktreeRecord, | 'connected' | 'lastOutputAt' | 'preview' | 'tabId' | 'paneKey' | 'title' | 'connectionId' | 'runtimeSessionOwned' | 'isWsl' | 'wslDistro' | 'incarnationId' > > = {} ): RuntimePtyWorktreeRecord { let pty = this.ptysById.get(ptyId) if (!pty) { const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null const connectionId = state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath const fallbackWslDistro = process.platform === 'win32' && connectionId === null && worktreePath ? parseWslUncPath(worktreePath)?.distro : undefined const wslDistro = connectionId === null ? (state.wslDistro ?? this.wslDistroByPtyId.get(ptyId) ?? fallbackWslDistro ?? null) : null pty = { ptyId, incarnationId: state.incarnationId ?? null, worktreeId, connectionId, runtimeSessionOwned: state.runtimeSessionOwned ?? false, isWsl: state.isWsl ?? null, wslDistro, tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, launchConfig: null, launchToken: null, launchAgent: null, foregroundAgent: null, connected: state.connected ?? true, disconnectedAt: state.connected === false ? Date.now() : null, lastExitCode: null, lastAgentStatus: null, lastOscTitle: null, lastOscTitleAt: null, lastOscTitleEpochMs: null, managementTitle: null, managementTitleAt: null, controllerTitle: null, title: state.title ?? null, titleUpdatedAt: titleObservedAt, lastOutputAt: state.lastOutputAt ?? null, tailBuffer: [], tailTranscriptBuffer: [], tailTranscriptChars: 0, tailPartialLine: '', tailPendingAnsi: '', tailRedrawCursor: null, tailTruncated: false, tailLinesTotal: 0, preview: state.preview ?? '', waitBlockedAt: null } if (state.title) { this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0) } this.ptysById.set(ptyId, pty) if (wslDistro) { this.wslDistroByPtyId.set(ptyId, wslDistro) } else if (connectionId !== null) { // Why: restored SSH IDs can collide with stale local parser state; connection ownership must win before their first output is parsed. this.wslDistroByPtyId.delete(ptyId) } // Why: restored/controller-discovered PTYs learn their worktree here without registerPty(), so URL enrichment must bind at this source. advertisedUrlWatcher.bindPty(ptyId, worktreeId) return pty } pty.worktreeId = worktreeId if (state.incarnationId !== undefined) { pty.incarnationId = state.incarnationId } if (state.connectionId !== undefined) { pty.connectionId = state.connectionId if (state.connectionId !== null) { pty.wslDistro = null this.wslDistroByPtyId.delete(ptyId) } } if (state.runtimeSessionOwned !== undefined) { pty.runtimeSessionOwned = state.runtimeSessionOwned } if (state.isWsl !== undefined) { pty.isWsl = state.isWsl } if (state.wslDistro !== undefined) { pty.wslDistro = state.wslDistro if (state.wslDistro) { this.wslDistroByPtyId.set(ptyId, state.wslDistro) } else { this.wslDistroByPtyId.delete(ptyId) } } if (state.tabId !== undefined) { pty.tabId = state.tabId } if (state.paneKey !== undefined) { pty.paneKey = state.paneKey } if (state.connected !== undefined) { pty.connected = state.connected pty.disconnectedAt = state.connected ? null : (pty.disconnectedAt ?? Date.now()) } if (state.lastOutputAt !== undefined) { pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt) } if (state.preview !== undefined && state.preview.length > 0) { pty.preview = state.preview } if (state.title !== undefined && state.title !== null && state.title.length > 0) { const observedAt = this.nextTitleObservationSequence() pty.title = state.title pty.titleUpdatedAt = observedAt this.setPtyManagementTitleFromObservedTitle(pty, state.title, observedAt) } // Why: recordPtyWorktree is the common lifecycle point for every path that resolves a PTY's worktree (renderer restore, controller list). advertisedUrlWatcher.bindPty(ptyId, worktreeId) return pty } private makeRuntimePaneKey( leaf: Pick ): string { return isTerminalLeafId(leaf.leafId) ? makePaneKey(leaf.tabId, leaf.leafId) : `${leaf.tabId}:${leaf.paneRuntimeId}` } private getOrCreatePtyWorktreeRecord(ptyId: string): RuntimePtyWorktreeRecord | null { const existing = this.ptysById.get(ptyId) if (existing) { return existing } const inferredWorktreeId = inferWorktreeIdFromPtyId(ptyId) if (!inferredWorktreeId) { return null } // Why: daemon-backed PTY session IDs are prefixed with the worktree ID so mobile summaries survive renderer graph gaps and reloads. return this.recordPtyWorktree(ptyId, inferredWorktreeId) } /** Synchronizes PTY tracking records with running daemon sessions, querying their foreground agent states. */ private async refreshPtyWorktreeRecordsFromController( resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null, deadline?: number ): Promise | null> { const inventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees, targetWorktreeId, deadline ) return inventory ? new Set(inventory.livePtyIds) : null } private async refreshPtyWorktreeRecordsWithControllerInventory( resolvedWorktrees: ResolvedWorktree[], targetWorktreeId: string | null = null, deadline?: number, connectionId?: string | null ): Promise { if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() if (targetedLiveness !== null) { return { livePtyIds: targetedLiveness, allLivePtyIds: targetedLiveness, terminalIdentityByPtyId: new Map() } } } if (!this.ptyController?.listProcesses) { return null } const inventoryGeneration = this.ptyControllerInventorySequence + 1 this.ptyControllerInventorySequence = inventoryGeneration const providerKey = typeof connectionId === 'string' ? `ssh:${connectionId}` : 'local' if (connectionId === undefined) { this.ptyControllerAggregateInventoryGeneration = inventoryGeneration } else { this.ptyControllerInventoryGenerationByProvider.set(providerKey, inventoryGeneration) } const sessionsResult = await withTimeoutResult( this.ptyController.listProcesses(connectionId), deadline === undefined ? PTY_CONTROLLER_LIST_TIMEOUT_MS : Math.max(1, Math.min(PTY_CONTROLLER_LIST_TIMEOUT_MS, deadline - Date.now())) ) if (!sessionsResult.ok) { // Why: a transient controller failure is not evidence that retained PTYs exited. return null } const isCurrentInventory = connectionId === undefined ? this.ptyControllerAggregateInventoryGeneration === inventoryGeneration && ![...this.ptyControllerInventoryGenerationByProvider.values()].some( (generation) => generation > inventoryGeneration ) : this.ptyControllerInventoryGenerationByProvider.get(providerKey) === inventoryGeneration && this.ptyControllerAggregateInventoryGeneration <= inventoryGeneration if (!isCurrentInventory) { return null } const sessions = sessionsResult.value const controllerIdentityByPtyId = new Map() const ptyIdByControllerHandle = new Map() const ambiguousControllerPtyIds = new Set() for (const session of sessions) { const handle = session.terminalHandle?.trim() const incarnationId = session.incarnationId?.trim() if (!handle?.startsWith('term_') || !incarnationId) { continue } const priorPtyId = ptyIdByControllerHandle.get(handle) if (priorPtyId && priorPtyId !== session.id) { ambiguousControllerPtyIds.add(priorPtyId) ambiguousControllerPtyIds.add(session.id) controllerIdentityByPtyId.delete(priorPtyId) continue } if (controllerIdentityByPtyId.has(session.id)) { ambiguousControllerPtyIds.add(session.id) controllerIdentityByPtyId.delete(session.id) continue } ptyIdByControllerHandle.set(handle, session.id) controllerIdentityByPtyId.set(session.id, { handle, incarnationId, ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}) }) } for (const ptyId of ambiguousControllerPtyIds) { controllerIdentityByPtyId.delete(ptyId) } const persistedIndexesByHostId = new Map< ExecutionHostId, { worktreeIdByPtyId: ReadonlyMap surfaceByPtyId: ReturnType } >() const getPersistedIndexes = (hostId: ExecutionHostId) => { const existing = persistedIndexesByHostId.get(hostId) if (existing) { return existing } const persistedSession = this.store?.getWorkspaceSession?.(hostId) const indexes = { worktreeIdByPtyId: indexPersistedPtyWorktreeBindings(persistedSession), surfaceByPtyId: indexPersistedPtySurfaceBindings(persistedSession) } persistedIndexesByHostId.set(hostId, indexes) return indexes } const allLivePtyIds = new Set(sessions.map((session) => session.id)) const selectedLivePtyIds = new Set() for (const session of sessions) { const sessionConnectionId = parseAppSshPtyId(session.id)?.connectionId ?? (typeof connectionId === 'string' ? connectionId : null) const persistedIndexes = getPersistedIndexes( sessionConnectionId ? toSshExecutionHostId(sessionConnectionId) : LOCAL_EXECUTION_HOST_ID ) const controllerIdentity = controllerIdentityByPtyId.get(session.id) const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) const providerWorktree = resolvedWorktrees.find( (worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId) ) const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) const persistedWorktree = persistedWorktreeId ? resolvedWorktrees.find((worktree) => runtimeWorktreeIdsEqual(worktree.id, persistedWorktreeId) ) : undefined const hasMigrationEvidence = Boolean(session.worktreeId) && !providerWorktree && Boolean(persistedWorktree) && Boolean(inferredWorktreeId) && runtimeWorktreeIdsEqual(session.worktreeId as string, inferredWorktreeId as string) // Why: an unresolved explicit provider owner remains authoritative unless the session id proves it was frozen before a persisted rename migration. const worktreeId = providerWorktree ? providerWorktree.id : hasMigrationEvidence ? (persistedWorktree?.id ?? null) : (session.worktreeId ?? persistedWorktree?.id ?? inferredWorktreeId ?? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)) const persistedSurface = persistedIndexes.surfaceByPtyId.get(session.id) const restoresExactSurface = persistedSurface && session.incarnationId && persistedSurface.incarnationId === session.incarnationId && Boolean(worktreeId) && runtimeWorktreeIdsEqual(persistedSurface.worktreeId, worktreeId as string) this.adoptControllerTerminalHandle( session.id, controllerIdentity?.handle ?? session.terminalHandle, controllerIdentity?.incarnationId ?? session.incarnationId, { exactRestoredSurface: Boolean(restoresExactSurface && controllerIdentity) } ) if ( !targetWorktreeId || (worktreeId && runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) ) { selectedLivePtyIds.add(session.id) } if ( targetWorktreeId && (!worktreeId || !runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) ) { const receipt = this.restoredOrchestrationAuthorityByPtyId.get(session.id) if (receipt && runtimeWorktreeIdsEqual(receipt.worktreeId, targetWorktreeId)) { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } continue } this.restoredOrchestrationAuthorityByPtyId.delete(session.id) if (worktreeId) { const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), ...(session.wslDistro !== undefined ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } : {}), ...(restoresExactSurface ? { tabId: persistedSurface.tabId, paneKey: persistedSurface.paneKey } : {}) }) if (restoresExactSurface && controllerIdentity) { this.rememberRestoredOrchestrationAuthority( pty, controllerIdentity.handle, controllerIdentity.incarnationId ) } else { this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } pty.controllerTitle = session.title?.trim() || null } // Why: fire-and-forget so this listing hot path doesn't serialize a relay round-trip per session and a throw can't abort the sweep below. this.refreshPtyForegroundAgent(session.id) } for (const [ptyId, receipt] of this.restoredOrchestrationAuthorityByPtyId) { const inScope = connectionId === undefined || (connectionId === null && receipt.hostScope.kind !== 'ssh') || (typeof connectionId === 'string' && receipt.hostScope.kind === 'ssh' && receipt.hostScope.targetId === connectionId) if (inScope && !allLivePtyIds.has(ptyId)) { this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) } } for (const pty of this.ptysById.values()) { if (connectionId !== undefined && pty.connectionId !== connectionId) { continue } if (!allLivePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) { if (this.ptyController.hasPty?.(pty.ptyId) === true) { // Why: an SSH spawn can become addressable before an overlapping relay list includes it. allLivePtyIds.add(pty.ptyId) if ( !targetWorktreeId || (pty.worktreeId && runtimeWorktreeIdsEqual(pty.worktreeId, targetWorktreeId)) ) { selectedLivePtyIds.add(pty.ptyId) } pty.connected = true pty.disconnectedAt = null continue } pty.connected = false pty.disconnectedAt ??= Date.now() } } this.pruneDisconnectedPtyRecords() return { livePtyIds: targetWorktreeId ? selectedLivePtyIds : allLivePtyIds, allLivePtyIds, terminalIdentityByPtyId: controllerIdentityByPtyId } } private refreshFloatingWorkspacePtyLiveness(): Set | null { const controller = this.ptyController if (!controller?.hasPty) { return null } const knownPtyIds = new Set() const persistedBindingByPtyId = new Map() for (const pty of this.ptysById.values()) { if (pty.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { knownPtyIds.add(pty.ptyId) } } for (const leaf of this.leaves.values()) { if (leaf.worktreeId === FLOATING_TERMINAL_WORKTREE_ID && leaf.ptyId) { knownPtyIds.add(leaf.ptyId) } } const snapshot = this.mobileSessionTabsByWorktree.get(FLOATING_TERMINAL_WORKTREE_ID) for (const tab of snapshot?.tabs ?? []) { if (tab.type !== 'terminal') { continue } if (tab.ptyId) { knownPtyIds.add(tab.ptyId) persistedBindingByPtyId.set(tab.ptyId, { tabId: tab.parentTabId, paneKey: this.getMobileTerminalPaneKey(tab) }) } for (const [leafId, ptyId] of Object.entries(tab.parentLayout?.ptyIdsByLeafId ?? {})) { knownPtyIds.add(ptyId) persistedBindingByPtyId.set(ptyId, { tabId: tab.parentTabId, paneKey: isTerminalLeafId(leafId) ? makePaneKey(tab.parentTabId, leafId) : `${tab.parentTabId}:${/^pane:(\d+)$/.exec(leafId)?.[1] ?? leafId}` }) } } const liveness = new Map() try { for (const ptyId of knownPtyIds) { const live = controller.hasPty(ptyId) if (live === null) { return null } liveness.set(ptyId, live) } } catch { return null } const livePtyIds = new Set() for (const [ptyId, live] of liveness) { let pty = this.ptysById.get(ptyId) if (live) { livePtyIds.add(ptyId) const binding = persistedBindingByPtyId.get(ptyId) if (!pty && binding) { // Why: a live daemon PTY restored from disk needs its pane identity before mobile can issue a safe handle. pty = this.recordPtyWorktree(ptyId, FLOATING_TERMINAL_WORKTREE_ID, { connected: true, tabId: binding.tabId, paneKey: binding.paneKey }) } if (pty) { pty.connected = true pty.disconnectedAt = null this.refreshPtyForegroundAgent(ptyId) } } else if (pty && !this.leafExistsForPty(ptyId)) { pty.connected = false pty.disconnectedAt ??= Date.now() } } this.pruneDisconnectedPtyRecords() return livePtyIds } private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void { if (pty.connected) { return } // Why: disconnected PTY records stay addressable for status/exit reads, but their transcripts must not accumulate after the process dies. pty.tailBuffer = [] pty.tailTranscriptBuffer = [] pty.tailTranscriptChars = 0 pty.tailPartialLine = '' pty.tailPendingAnsi = '' pty.tailRedrawCursor = null pty.tailTruncated = false pty.tailLinesTotal = 0 pty.waitBlockedAt = null // Why: tail is now empty, so clear the memoized wait scan; onPtyData must recompute from the reset tail if this record resumes output. pty.tailWaitState = undefined } private pruneDisconnectedPtyRecords(): void { const retained = [...this.ptysById.values()] .filter((pty) => !pty.connected && !this.leafExistsForPty(pty.ptyId)) .sort((a, b) => (a.disconnectedAt ?? 0) - (b.disconnectedAt ?? 0)) const staleCount = Math.max(0, retained.length - DISCONNECTED_PTY_RECORD_MAX) for (const stale of retained.slice(0, staleCount)) { // Why: exited runtime-owned PTYs stay readable, but long-lived runtimes churn through many sessions; bound the archive. this.dropDisconnectedPtyRecord(stale.ptyId) } } private dropDisconnectedPtyRecord(ptyId: string): void { // Why: pruning can remove a PTY without the normal exit callback. this.advancePtyLifecycleGeneration(ptyId) this.pairedRendererSessionOwnedPtyIds.delete(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) this.setupCompletionTokenByPtyId.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.providerSequenceInitializedPtys.delete(ptyId) this.providerSequenceOffsetByPtyId.delete(ptyId) this.providerSnapshotPreferredPtys.delete(ptyId) this.providerModeTrackersByPtyId.delete(ptyId) this.providerModeSnapshotScansByPtyId.delete(ptyId) this.providerBufferAcquisitionsByPtyId.delete(ptyId) this.providerVisibleStateByPtyId.delete(ptyId) this.providerVisibleRetryAtByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) this.terminalSpawnCommandsByPtyId.delete(ptyId) this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { // Why: pruning can remove a PTY without onPtyExit firing; release this leader's agent team so it doesn't leak. this.claudeAgentTeams.removeTeamForLeaderHandle(handle) this.handleByPtyId.delete(ptyId) this.syntheticTerminalHandles.delete(handle) const record = this.handles.get(handle) if (record?.tabId.startsWith('pty:')) { this.handles.delete(handle) } } } private leafExistsForPty(ptyId: string): boolean { return (this.leavesByPtyId.get(ptyId)?.length ?? 0) > 0 } private rebuildLeafPtyIndex(): void { const next = new Map() for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { continue } const leaves = next.get(leaf.ptyId) if (leaves) { leaves.push(leaf) } else { next.set(leaf.ptyId, [leaf]) } } this.leavesByPtyId = next } private getLeavesForPty(ptyId: string): RuntimeLeafRecord[] { return this.leavesByPtyId.get(ptyId) ?? [] } private getSummaryForRuntimeWorktreeId( summaries: Map, runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds: Set, runtimeWorktreeId: string ): RuntimeWorktreePsSummary | null { const exact = summaries.get(runtimeWorktreeId) if (exact) { return exact } if (missingRuntimeWorktreeIds.has(runtimeWorktreeId)) { return null } const parsed = parseRuntimeWorktreeId(runtimeWorktreeId) if (!parsed) { return null } const comparisonPlatform = runtimeWorktreeSummaryPathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform const indexed = findRuntimeWorktreeSummaryByPath( runtimeWorktreeSummaryPathIndex, parsed.repoId, parsed.worktreePath, comparisonPlatform ) if (indexed) { return indexed } missingRuntimeWorktreeIds.add(runtimeWorktreeId) return null } private buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map, provenLivePtyIds: ReadonlySet | null = null ): RuntimeTerminalSummary { const worktree = worktreesById.get(leaf.worktreeId) const tab = this.tabs.get(leaf.tabId) ?? null const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined // Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a // restored surface whose PTY died with a prior run still reads connected. // Demote only on a controller-proven absence, and only for locally-scoped // ids the aggregate inventory authoritatively covers — SSH/remote scopes may // be legitimately missing from it, and unknown liveness never demotes. // The sync hasPty rescue closes the spawn/list race: a just-spawned PTY can // register after the inventory snapshot, and federation reads one // connected:false as exited. const provenAbsent = provenLivePtyIds !== null && leaf.ptyId !== null && !provenLivePtyIds.has(leaf.ptyId) && !leaf.ptyId.startsWith('remote:') && parseAppSshPtyId(leaf.ptyId) === null && this.ptyController?.hasPty?.(leaf.ptyId) !== true return { handle: this.issueHandle(leaf), ptyId: leaf.ptyId, incarnationId: pty?.incarnationId ?? null, orphaned: false, worktreeId: leaf.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', tabId: leaf.tabId, leafId: leaf.leafId, title: getLatestLeafTitle(leaf, tab?.title ?? null), connected: provenAbsent ? false : leaf.connected, writable: provenAbsent ? false : leaf.writable, lastOutputAt: leaf.lastOutputAt, preview: leaf.preview } } // Returns the worktrees whose stored snapshot object changed during this // sync, so the caller can fan out only actually-changed worktrees. private syncMobileSessionTabs( snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined, unchangedWorktreeIds?: string[], resyncWorktreeIds: Set = new Set() ): Set { const changedWorktreeIds = new Set() if (snapshots === undefined) { return changedWorktreeIds } // Why: snapshots are immutable — every writer replaces the map entry with a // new object, and the accept gate below drops semantically-unchanged // renderer resends before they replace an entry — so reference identity // before/after detects exactly the entries that actually changed. const before = new Map(this.mobileSessionTabsByWorktree) this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, { missingSnapshotOnly: true, notify: false }) // Why: graph sync must scan each persisted host session once, not once per workspace. const worktreeSessionsToHydrate = new Map( this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend)) ) if (this.offscreenBrowserBackend) { for (const snapshot of snapshots) { if (!worktreeSessionsToHydrate.has(snapshot.worktree)) { worktreeSessionsToHydrate.set(snapshot.worktree, null) } } } // Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition. for (const [worktreeId, workspaceSession] of worktreeSessionsToHydrate) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true, ...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {}) }) } const nextWorktrees = new Set() const incomingWorktreeIds = new Set(snapshots.map((snapshot) => snapshot.worktree)) // Why: the renderer withholds unchanged snapshots to keep the graph payload // small, so these worktrees are still live and must not fall into the prune // below. Ask for a republish when main no longer holds that accepted renderer // publication or a formerly-preserved runtime tab has gone stale. for (const worktreeId of unchangedWorktreeIds ?? []) { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(worktreeId) if (existing) { nextWorktrees.add(worktreeId) } if ( existing && accepted && (existing.publicationEpoch === accepted.publicationEpoch || existing.publicationEpoch.startsWith(`${accepted.publicationEpoch}:headless-merge:`)) && existing.tabs.length >= accepted.rendererTabCount && (existing.tabs.length === accepted.rendererTabCount || !this.storedMobileSnapshotHasStalePreservedTab( existing, accepted.rendererTabIdentityKeys )) ) { continue } if (!incomingWorktreeIds.has(worktreeId)) { resyncWorktreeIds.add(worktreeId) } // Why: the accept gate compares against the renderer's last accepted pair, // which outlives the dropped snapshot and would reject the republish. this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) } for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) // Why: judge renderer publication ordering against the renderer's own // last-accepted (epoch, version) — the renderer reuses one pair for // byte-identical content, so a same-epoch version <= the accepted one is // a no-op resend (or a stale frame) and must be skipped. Never compare // against the stored snapshot's version: main-local touches bump it // independently and would reject genuinely newer renderer revisions. const accepted = this.acceptedRendererMobileSnapshotByWorktree.get(snapshot.worktree) if ( accepted && accepted.publicationEpoch === snapshot.publicationEpoch && snapshot.snapshotVersion <= accepted.rendererVersion && // Why: preservation is main-only state — a serve/SSH binding (or live // browser page) can disappear without the renderer bumping its version, // so a resend of the EXACT accepted revision (content-identical to the // accepted publication, safe to re-merge) must still fall through to // the merge, which prunes stale preserved tabs. Strictly-older frames // stay skipped: their content is outdated, and the next accepted-pair // resend performs the prune. !( existing && snapshot.snapshotVersion === accepted.rendererVersion && this.storedMobileSnapshotHasStalePreservedTab(existing, accepted.rendererTabIdentityKeys) ) ) { continue } this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot) const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot) const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot) const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing) // Why: clients drop same-epoch frames whose version isn't strictly newer, // and main-local touches may already have emitted a higher version than // the renderer's counter — keep the stored version strictly monotonic so // the accepted content is never discarded as stale downstream. const storedVersion = existing ? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1) : nextSnapshot.snapshotVersion this.mobileSessionTabsByWorktree.set( snapshot.worktree, storedVersion === nextSnapshot.snapshotVersion ? nextSnapshot : { ...nextSnapshot, snapshotVersion: storedVersion } ) this.acceptedRendererMobileSnapshotByWorktree.set(snapshot.worktree, { publicationEpoch: snapshot.publicationEpoch, rendererVersion: snapshot.snapshotVersion, rendererTabCount: fencedSnapshot.tabs.length, rendererTabIdentityKeys: new Set( fencedSnapshot.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ) }) } for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { if (!nextWorktrees.has(worktreeId)) { const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) if (preserved) { // Why: preservation filters existing.tabs in place (same objects) and // the merge epoch hashes the preserved identities idempotently, so an // equal epoch with every tab object retained means the recomputation // was a no-op — keep the entry so no-op syncs don't fan out. const preservedIsNoOp = preserved.publicationEpoch === existing.publicationEpoch && preserved.tabs.length === existing.tabs.length && preserved.tabs.every((tab, index) => tab === existing.tabs[index]) if (!preservedIsNoOp) { this.mobileSessionTabsByWorktree.set(worktreeId, preserved) } // Why: the stored entry is no longer the renderer's publication, so a // future renderer frame must be re-merged even if it reuses the pair. this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } } for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { if (before.get(worktreeId) !== snapshot) { changedWorktreeIds.add(worktreeId) } } return changedWorktreeIds } private mergePreservedHeadlessMobileSessionTabs( snapshot: RuntimeMobileSessionTabsSnapshot, existing: RuntimeMobileSessionTabsSnapshot | undefined ): RuntimeMobileSessionTabsSnapshot { if (!existing) { return snapshot } const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot) if (preservedTabs.length === 0) { return snapshot } const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) const normalizedPreservedTabs = preservedTabs.map((tab) => hasIncomingActiveTab ? { ...tab, isActive: false } : tab ) const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs) if (tabs.length === snapshot.tabs.length) { return snapshot } const activeTab = snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? tabs.find((tab) => tab.id === existing.activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0] ?? null const terminalTabs = tabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) return { ...snapshot, publicationEpoch: this.getMergedMobileSessionPublicationEpoch( snapshot, normalizedPreservedTabs ), snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion), activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, tabGroups: this.mergeMobileSessionTabGroups( snapshot.worktree, snapshot.tabGroups ?? existing.tabGroups ?? [], terminalTabs, activeTab?.type === 'terminal' ? activeTab : null ), tabs } } private buildPreservedHeadlessMobileSessionSnapshot( existing: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot | null { const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing) if (tabs.length === 0) { return null } const activeTab = tabs.find((tab) => tab.id === existing.activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0] ?? null const terminalTabs = tabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) return { ...existing, publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), // Why: mint a fresh version or clients' same-epoch gate drops the prune frame. snapshotVersion: existing.snapshotVersion + 1, activeGroupId: existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, tabGroups: this.mergeMobileSessionTabGroups( existing.worktree, existing.tabGroups ?? [], terminalTabs, activeTab?.type === 'terminal' ? activeTab : null ), tabs } } // Why: the accepted-revision no-op gate must not fossilize preserved runtime // tabs. A stored merged snapshot's tabs absent from the accepted renderer // publication exist only via preservation; if any such tab no longer // passes the preservation predicate (binding removed from the live PTY table // and persisted session, or browser page closed), the stored snapshot is stale. private storedMobileSnapshotHasStalePreservedTab( existing: RuntimeMobileSessionTabsSnapshot, rendererTabIdentityKeys: ReadonlySet ): boolean { return existing.tabs.some( (tab) => !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => rendererTabIdentityKeys.has(id) ) && !this.shouldPreserveHeadlessMobileSessionTab(existing, tab) ) } private collectPreservedHeadlessMobileSessionTabs( existing: RuntimeMobileSessionTabsSnapshot, incoming?: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionSnapshotTab[] { const incomingIds = new Set( incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] ) return existing.tabs.filter((tab) => { if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { return false } return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) }) } private shouldPreserveHeadlessMobileSessionTab( snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionSnapshotTab ): boolean { // Why: headless offscreen browser tabs exist only server-side, so a renderer-graph merge must keep them, not prune as "not in the graph". if (tab.type === 'browser') { if (!this.offscreenBrowserBackend) { return false } // Why: in a renderer-based merged snapshot the browser entries can also // be renderer-owned, so only pages the offscreen bridge still lists are // runtime-owned and preservable; a pure renderer epoch preserves none. return ( this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || (snapshot.publicationEpoch.includes(':headless-merge:') && typeof tab.browserPageId === 'string' && this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)) ) } if (tab.type !== 'terminal') { return false } // Why: a merged renderer snapshot carries BOTH renderer-owned and // runtime-owned tabs, so the epoch alone must not preserve every terminal — // that resurrects renderer tabs the renderer already closed. Broad // preservation applies only to genuinely headless-built snapshots; in a // renderer-based one, only tabs with a live-or-persisted serve/SSH binding // are runtime-owned and preservable. return ( this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) || this.hasLiveRuntimeSessionOwnedPtyBinding(snapshot.worktree, tab) || this.hasLiveOrPersistedServeOrSshOwnedPtyBinding(snapshot.worktree, tab) ) } private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { return ( publicationEpoch.startsWith('headless:') || publicationEpoch.startsWith('headless-hydrated:') || publicationEpoch.includes(':headless-merge:') ) } // Why: `:headless-merge:` only marks that runtime tabs were merged in — the // BASE epoch still says who published the snapshot. A renderer-based merged // snapshot must not be classified as headless-built, or its renderer tabs // read as runtime-owned. private isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean { const base = publicationEpoch.split(':headless-merge:')[0] return base.startsWith('headless:') || base.startsWith('headless-hydrated:') } private getMergedMobileSessionPublicationEpoch( snapshot: RuntimeMobileSessionTabsSnapshot, preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] ): string { // Why: preserved snapshots can merge repeatedly; strip the prior merge suffix first so the publication epoch stays idempotent. const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] const signature = createHash('sha1') .update( preservedTabs .map((tab) => tab.type === 'terminal' ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` : tab.id ) .join('|') ) .digest('hex') .slice(0, 12) return `${normalizedPublicationEpoch}:headless-merge:${signature}` } private notifyMobileSessionTabsRemoved(worktreeId: string): void { const removed: RuntimeMobileSessionTabsRemovedResult = { worktree: worktreeId, publicationEpoch: `removed:${Date.now().toString(36)}`, snapshotVersion: 0, removed: true, activeGroupId: null, activeTabId: null, activeTabType: null, tabs: [] } for (const subscription of this.mobileSessionTabListeners) { subscription.listener( this.clientSessionTabSelections.project(removed, subscription.clientNavigationId) ) } this.clientSessionTabSelections.forgetWorktree(worktreeId) } notifyMobileSessionTabsChanged(worktreeId?: string): void { if (!worktreeId) { this.notifyMobileSessionTabSnapshots() return } if (this.offscreenBrowserBackend) { const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) // Why: hydrate already reconciles an existing snapshot in place; only reconcile here when it didn't (fresh build or early-returned hydrate). if (!reconciled.has(worktreeId)) { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) if (existing) { this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) } } } // Why: structural changes must propagate promptly; cancel any pending coalesced notify since this immediate emit supersedes it. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsChangedNow(worktreeId) } private notifyMobileSessionTabsChangedNow(worktreeId: string): void { if (this.mobileSessionTabListeners.size === 0) { return } const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { return } // Why: browser bridge events are already worktree-scoped; don't fan out every workspace snapshot during navigation/tab churn. const result = this.toMobileSessionTabsResult(snapshot) for (const subscription of this.mobileSessionTabListeners) { subscription.listener( this.clientSessionTabSelections.project(result, subscription.clientNavigationId) ) } } private notifyMobileSessionTabSnapshots(): void { if (this.mobileSessionTabListeners.size === 0) { return } for (const snapshot of this.mobileSessionTabsByWorktree.values()) { const result = this.toMobileSessionTabsResult(snapshot) for (const subscription of this.mobileSessionTabListeners) { subscription.listener( this.clientSessionTabSelections.project(result, subscription.clientNavigationId) ) } } } private getMobileSessionTabsForWorktree( worktreeId: string, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (!snapshot) { return this.clientSessionTabSelections.project( { worktree: worktreeId, publicationEpoch: 'none', snapshotVersion: 0, activeGroupId: null, activeTabId: null, activeTabType: null, tabs: [] }, clientNavigationId ) } return this.clientSessionTabSelections.project( this.toMobileSessionTabsResult(snapshot), clientNavigationId ) } private emitMobileSessionTabsSnapshotToClient( projected: RuntimeMobileSessionTabsResult, clientNavigationId: string, follow = false ): void { for (const subscription of this.mobileSessionTabListeners) { if (subscription.clientNavigationId === clientNavigationId) { subscription.listener(follow ? { ...projected, navigationIntent: 'follow' } : projected) } } } private async resolveMobileMarkdownWorktreeId( worktreeSelector: string, tabId: string ): Promise { const worktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) ?? (await this.resolveWorktreeSelector(worktreeSelector)).id const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) const tab = snapshot?.tabs.find( (candidate): candidate is RuntimeMobileSessionMarkdownTab => candidate.type === 'markdown' && candidate.id === tabId ) if (!tab) { throw new Error('tab_not_found') } return worktreeId } private getLiveBrowserTabsByPageId(worktreeId: string): Map { if (!this.agentBrowserBridge?.tabList) { return new Map() } const liveTabs = this.agentBrowserBridge.tabList(worktreeId).tabs return new Map(liveTabs.map((tab) => [tab.browserPageId, tab])) } private collectReturnedSessionTabIds( tabs: readonly RuntimeMobileSessionClientTab[] ): Set { const ids = new Set() for (const tab of tabs) { ids.add(tab.id) if (tab.type === 'terminal') { ids.add(tab.parentTabId) } else if (tab.type === 'browser') { ids.add(tab.browserWorkspaceId) } } return ids } private sanitizeMobileSessionTabGroups( groups: readonly RuntimeMobileSessionTabGroup[] | undefined, returnedTabs: readonly RuntimeMobileSessionClientTab[] ): RuntimeMobileSessionTabGroup[] | undefined { if (!groups || groups.length === 0) { return undefined } const returnedIds = this.collectReturnedSessionTabIds(returnedTabs) const sanitized = groups .map((group): RuntimeMobileSessionTabGroup | null => { const tabOrder = group.tabOrder.filter((tabId) => returnedIds.has(tabId)) if (tabOrder.length === 0) { return null } const activeTabId = group.activeTabId && tabOrder.includes(group.activeTabId) ? group.activeTabId : (tabOrder[0] ?? null) const recentTabIds = group.recentTabIds?.filter((tabId) => tabOrder.includes(tabId)) return { id: group.id, activeTabId, tabOrder, ...(recentTabIds && recentTabIds.length > 0 ? { recentTabIds } : {}) } }) .filter((group): group is RuntimeMobileSessionTabGroup => group !== null) return sanitized.length > 0 ? sanitized : undefined } private pruneMobileSessionTabGroupLayout( layout: TabGroupLayoutNode | null | undefined, validGroupIds: ReadonlySet ): TabGroupLayoutNode | null { if (!layout) { return null } if (layout.type === 'leaf') { return validGroupIds.has(layout.groupId) ? layout : null } const first = this.pruneMobileSessionTabGroupLayout(layout.first, validGroupIds) const second = this.pruneMobileSessionTabGroupLayout(layout.second, validGroupIds) if (first && second) { return { ...layout, first, second } } return first ?? second } /** Transforms an internal mobile session tab snapshot into a sanitized client payload, resolving launch-agent ownership and normalizing titles. */ private toMobileSessionTabsResult( snapshot: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsResult { const tabs: RuntimeMobileSessionClientTab[] = [] const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) // Production reads hook rows by pane; the snapshot fallback remains for tests // and embedders that have not adopted the narrow getter. let hookRowsByPaneKey: Map | null = null const hookRowsForPane = new Map() const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { const cached = hookRowsForPane.get(paneKey) if (cached) { return cached } const direct = this.getAgentProviderSessionRowsForPaneFn?.(paneKey) if (direct) { hookRowsForPane.set(paneKey, direct) return direct } hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey( this.getAgentProviderSessionSnapshotFn?.() ?? [] ) const rows = hookRowsByPaneKey.get(paneKey) ?? [] hookRowsForPane.set(paneKey, rows) return rows } // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { if (tab.type === 'browser') { const liveTab = tab.browserPageId ? liveBrowserTabsByPageId.get(tab.browserPageId) : undefined if (!liveTab) { continue } // Why: renderer snapshots lag BrowserView teardown/process swaps; only surface pages the browser bridge can still route to. tabs.push({ ...tab, title: liveTab.title || tab.title, url: liveTab.url || tab.url, // Why: bridge "active" means active BrowserView/webContents, not active Orca tab; preserve the renderer's session focus. isActive: tab.isActive }) continue } if (tab.type === 'markdown' || tab.type === 'file') { tabs.push(tab) continue } const syncedTab = this.tabs.get(tab.parentTabId) const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null const liveLeafPtyId = liveLeaf?.ptyId ?? null const liveLeafPty = liveLeafPtyId ? (this.ptysById.get(liveLeafPtyId) ?? null) : null const pty = liveLeaf ? null : this.findPtyForMobileTerminalTab(snapshot.worktree, tab, { allowWorktreeOnlyMatch: !snapshot.publicationEpoch.startsWith('headless') }) const livePty = pty?.connected ? pty : null // Why: enforce one-live-PTY-per-tab; drop a later tab resolving to an already-claimed PTY so no two tabs share a handle. const resolvedLivePtyId = liveLeafPtyId ?? livePty?.ptyId ?? null if (resolvedLivePtyId !== null) { if (claimedLivePtyIds.has(resolvedLivePtyId)) { continue } claimedLivePtyIds.add(resolvedLivePtyId) } const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` const mobileStatusPty = livePty ?? pty // Why: headless hooks live only in main's retained rows; reuse this lookup // for both title ownership and status publication so the two cannot diverge. const retainedAgentStatus = tab.agentStatus ? null : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) const hookAgentStatus = tab.agentStatus ? this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) : null const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } ) : null const ptyTitle = pty ? getLatestAgentCandidateTitle( { title: pty.title, updatedAt: pty.titleUpdatedAt }, { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) : null const launchAgent = tab.launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. const ownerAgent = resolvePaneAgentOwner({ launchAgent, hookAgent: tab.agentStatus?.agentType ?? hookAgentStatus?.agentType ?? retainedAgentStatus?.payload.agentType ?? null }) ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null const title = normalizeCompatibleAgentTitleForOwner( leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, ownerAgent ) const liveTitleEvidence = leafTitle ?? ptyTitle // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. const rendererStatusAgent = resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ?? ownerAgent ?? undefined const hookSessionAgent = resolveCompatibleAgentTypeForOwner( hookAgentStatus?.providerSessionAgentType, ownerAgent ) const hookSessionMatchesRenderer = !rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent const hookProviderSession = hookAgentStatus?.providerSession && hookSessionMatchesRenderer && (!tab.agentStatus?.providerSession || (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) ? hookAgentStatus.providerSession : tab.agentStatus?.providerSession const normalizedTabAgentStatus = tab.agentStatus ? normalizeCompatibleAgentStatusEntryForOwner( { ...tab.agentStatus, ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) }, ownerAgent ) : null // Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost. const hasLiveAgentSignal = normalizedTabAgentStatus?.interactivePrompt != null || normalizedTabAgentStatus?.toolName != null // Why: only shell/management evidence proves the agent released the pane // (same predicate as the terminal-status API). A merely neutral live title // — 'Terminal', an editor, a cwd — proves nothing, and treating it as // completion published a synthetic `done` that fought the client's own // live status on every republication. const keepFullAgentStatus = normalizedTabAgentStatus && (!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal) const agentStatus = keepFullAgentStatus ? { agentStatus: normalizedTabAgentStatus } : // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript. normalizedTabAgentStatus?.agentType != null ? { agentStatus: { state: 'done' as const, prompt: '', updatedAt: normalizedTabAgentStatus.updatedAt, stateStartedAt: normalizedTabAgentStatus.stateStartedAt, paneKey: normalizedTabAgentStatus.paneKey, stateHistory: [], agentType: normalizedTabAgentStatus.agentType, ...(normalizedTabAgentStatus.providerSession ? { providerSession: normalizedTabAgentStatus.providerSession } : {}) } } : null // Why: web/mobile clients hold handles across renderer graph syncs; leaf handles are epoch-bound but PTY handles stay streamable. const terminalHandle = liveLeafPtyId ? this.issuePtyHandle( this.recordPtyWorktree(liveLeafPtyId, snapshot.worktree, { tabId: tab.parentTabId, paneKey, connected: true }) ) : livePty ? this.issuePtyHandle(livePty) : null tabs.push({ type: 'terminal', id: tab.id, parentTabId: tab.parentTabId, leafId: tab.leafId, title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), ...(launchAgent ? { launchAgent } : {}), ...(agentStatus ?? this.buildPtyMobileAgentStatus( mobileStatusPty, tab, terminalHandle, retainedAgentStatus, getHookRowsForPane )), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), ...(tab.color != null ? { color: tab.color } : {}), ...(tab.isPinned ? { isPinned: true } : {}), ...(tab.viewMode ? { viewMode: tab.viewMode } : {}), ...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}), ...(tab.launchDraftCreatedAt !== undefined ? { launchDraftCreatedAt: tab.launchDraftCreatedAt } : {}), isActive: tab.isActive, ...(terminalHandle ? { status: 'ready' as const, terminal: terminalHandle } : { status: 'pending-handle' as const, terminal: null }) }) } const active = tabs.find((tab) => tab.isActive && tab.id === snapshot.activeTabId) ?? tabs.find((tab) => tab.isActive) ?? (snapshot.activeTabId ? (tabs[0] ?? null) : null) const normalizedTabs = active && !tabs.some((tab) => tab.isActive) ? tabs.map((tab) => (tab.id === active.id ? { ...tab, isActive: true } : tab)) : tabs const tabGroups = this.sanitizeMobileSessionTabGroups(snapshot.tabGroups, normalizedTabs) const validGroupIds = new Set(tabGroups?.map((group) => group.id) ?? []) const tabGroupLayout = snapshot.tabGroupLayout === undefined ? undefined : this.pruneMobileSessionTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) const activeGroupId = snapshot.activeGroupId && validGroupIds.has(snapshot.activeGroupId) ? snapshot.activeGroupId : (tabGroups?.find((group) => active ? group.tabOrder.some((tabId) => this.collectReturnedSessionTabIds([active]).has(tabId) ) : false )?.id ?? tabGroups?.[0]?.id ?? null) return { worktree: snapshot.worktree, publicationEpoch: snapshot.publicationEpoch, snapshotVersion: snapshot.snapshotVersion, activeGroupId, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null, ...(tabGroups ? { tabGroups } : {}), ...(snapshot.tabGroupLayout !== undefined ? { tabGroupLayout } : {}), tabs: normalizedTabs } } /** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */ private buildPtyMobileAgentStatus( pty: RuntimePtyWorktreeRecord | null, tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null, retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) // Why: neither the OSC-retained row nor a title-derived status can carry a // provider session — only the hook payload does, and headless serve has no // renderer to publish `tab.agentStatus`. Without it mobile native chat has no // transcript to address and sits on the empty state forever. const hookRow = this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) // Why: the hook row is evidence in its own right. Returning early on a missing // PTY status/retained row put this check ahead of the only headless carrier, so // an agent that reported its session but never emitted a recognized title got no // `agentStatus` at all — exactly the hook-only case the fallback exists for. if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { return {} } const providerSession = hookRow.providerSession ? { providerSession: hookRow.providerSession } : {} const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null const ptyTitle = pty ? getLatestAgentCandidateTitle( { title: pty.title, updatedAt: pty.titleUpdatedAt }, { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) : leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } ) : null const ptyTitleClassification = classifyAgentTitle(ptyTitle) const nonAgentTitle = ptyTitle !== null && ptyTitleClassification !== 'agent' if (nonAgentTitle) { // Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives. const hasLiveHookSignal = retained?.payload.interactivePrompt != null || retained?.payload.toolName != null || // Why: a pending question is never inherited across hook events (unlike // `toolName`), so it proves the agent is parked on a selector right now. hookRow.live?.payload.interactivePrompt != null || // Why: headless serve has no renderer to retain an OSC row, so a fresh hook // agentType is the only live signal a hook-only pane can offer — and an agent // that reports over HTTP need never set a title this gate would recognize. // Scoped to panes with no PTY status at all, so it cannot revive a spinner: // this branch publishes `done`. It only keeps the transcript addressable. (!pty?.lastAgentStatus && (hookRow.agentType != null || hookRow.providerSession != null)) if (!hasLiveHookSignal) { return {} } } // Why: a retained OMP hook stays stable while wrapper foreground reads can report Pi. const ownerAgent = resolvePaneAgentOwner({ launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, hookAgent: retained?.payload.agentType ?? hookRow.agentType }) ?? pty?.foregroundAgent ?? null const terminalTitle = normalizeCompatibleAgentTitleForOwner( (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, ownerAgent ) // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) if (liveRow) { return { agentStatus: normalizeCompatibleAgentStatusEntryForOwner( { ...liveRow.payload, paneKey, updatedAt: liveRow.updatedAt, stateStartedAt: liveRow.stateStartedAt, stateHistory: [], ...(terminalHandle ? { terminalHandle } : {}), ...((pty?.worktreeId ?? liveRow.worktreeId) ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } : {}), tabId: tab.parentTabId, terminalTitle, ...providerSession }, ownerAgent ) } } // Last resort: the pane's hook evidence is identity only (resume rows, stale // rows, or a row the freshness gate rejected). `done` is the honest // projection — and it is what retires the card once the agent exits. // Why not lastOutputAt: this state is title-derived, so it must be dated by // its evidence. Stamping it with the byte stream made the frame advance on // every output byte, so a paired client's live status could never outrank it. const evidenceAt = pty?.lastOscTitleEpochMs ?? hookRow.providerSessionReceivedAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: pty?.lastAgentStatus === 'working' ? 'working' : pty?.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', updatedAt: evidenceAt, stateStartedAt: evidenceAt, paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), ...(pty?.worktreeId ? { worktreeId: pty.worktreeId } : {}), tabId: tab.parentTabId, terminalTitle, stateHistory: [], ...providerSession } } } /** Live hook status to publish for a pane with no retained OSC row, or null when the * pane's hook evidence only proves identity. * * Why the freshness rule: `pty.lastAgentStatus` is title-derived and refreshed live, * so an unconditional hook precedence would let a 29-minute-old `done` erase a pane * that is visibly working. A pending `interactivePrompt` outranks title evidence at * any age — the agent is parked on a selector until it answers — and it is also the * only signal allowed to survive the #1437 non-agent-title suppression. */ private resolveHookLiveAgentRow( live: HookLiveAgentRow | null, pty: RuntimePtyWorktreeRecord | null, nonAgentTitle: boolean ): HookLiveAgentRow | null { if (!live) { return null } if (live.payload.interactivePrompt != null) { return live } // Why only this stamp: it is the sole wall-clock date on the pane's live title, // so it is the only one comparable to a hook `receivedAt`. The sibling // `titleUpdatedAt`/`lastOscTitleAt`/`paneTitleUpdatedAt` fields are observation // sequence numbers, and comparing them here can only ever misfire. return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null } /** Hook-reported identity for this pane, newest wins per field. * * `providerSession` is deliberately unbounded: it is resume identity, not live * state, it stays correct until the pane relaunches (which overwrites the row * under the same paneKey), and it is only ever read once an agent is already * established. Bounding it would blank mobile native chat on an idle session. * * `agentType` is bounded by the same staleness window the retained OSC path uses, * because it is the signal that claims an agent owns the pane at all. A user who * exits the agent leaves `pty.lastAgentStatus` behind forever, so an unbounded * read would keep offering native chat for what is now a plain shell. * * `live` is the newest fresh row's status fields — bounded like `agentType` because * it asserts liveness, and unlike `agentType` it excludes `providerSessionOnly` rows * with no Pi exception: those carry resume identity, and their status-shaped fields * are documented transport placeholders that must never reach a client. It also drops * `restoredUnconfirmed` rows, which `isFreshNonDoneAgentStatus` already treats as * never-fresh; they still count as `agentType` identity evidence. */ private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { providerSession: AgentProviderSessionMetadata | null providerSessionAgentType: string | null providerSessionReceivedAt: number | null agentType: string | null live: HookLiveAgentRow | null } { let session: AgentStatusIpcPayload | null = null let agent: AgentStatusIpcPayload | null = null let live: AgentStatusIpcPayload | null = null const agentTypeFreshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS // Why pane key only: the sibling `terminalHandle` arm this used to carry never // matched. `toAgentStatusIpcPayload` does not emit the field on the hook path // (only the renderer's own store stamps it, and headless serve has no renderer), // and because it is optional TypeScript could not flag the dead comparison. for (const entry of rows) { if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) { session = entry } if ( entry.agentType && (entry.providerSessionOnly !== true || (entry.agentType === 'pi' && entry.providerSession != null)) && entry.receivedAt >= agentTypeFreshAfter && (!agent || entry.receivedAt > agent.receivedAt) ) { agent = entry } if ( entry.providerSessionOnly !== true && // Why: a row hydrated from last-status.json describes a turn that may have ended // while no receiver was up (#12346), so its `receivedAt` cannot prove liveness — // publishing it would resurrect a zombie question card across a restart. entry.restoredUnconfirmed !== true && entry.receivedAt >= agentTypeFreshAfter && (!live || entry.receivedAt > live.receivedAt) ) { live = entry } } return { providerSession: session?.providerSession ?? null, providerSessionAgentType: session?.agentType ?? null, providerSessionReceivedAt: session?.receivedAt ?? null, agentType: agent?.agentType ?? null, live: live ? { payload: pickParsedAgentStatusPayload(live), updatedAt: live.receivedAt, stateStartedAt: live.stateStartedAt ?? live.receivedAt, ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) } : null } } /** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). */ private getFreshRetainedAgentStatusForMobileTab( paneKey: string, pty: RuntimePtyWorktreeRecord | null, tab: RuntimeMobileSessionTerminalTab ): RuntimeAgentRowSnapshot | null { let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null if (!retained) { const ptyId = pty?.ptyId ?? tab.ptyId ?? null if (ptyId) { for (const snapshot of this.latestAgentStatusByPaneKey.values()) { if (snapshot.ptyId !== ptyId) { continue } if (!retained || snapshot.updatedAt > retained.updatedAt) { retained = snapshot } } } } if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { return null } return retained } private findPtyForMobileTerminalTab( worktreeId: string, tab: RuntimeMobileSessionTerminalTab, options: { allowWorktreeOnlyMatch?: boolean } = {} ): RuntimePtyWorktreeRecord | null { const snapshotPtyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null const paneKey = this.getMobileTerminalPaneKey(tab) if (snapshotPtyId) { const pty = this.ptysById.get(snapshotPtyId) if (!pty) { return null } // Why: persisted PTY ids can collide with unrelated provider ids after restart; only a matching spawn-time pane identity is safe to expose. if (this.mobileTerminalTabMatchesPty(worktreeId, tab, pty, paneKey)) { return pty } if ( options.allowWorktreeOnlyMatch === true && pty.worktreeId === worktreeId && pty.tabId === null && pty.paneKey === null ) { return pty } return null } const paneKeys = new Set([`${tab.parentTabId}:${tab.leafId}`]) if (tab.leafId === `pane:${FIRST_PANE_ID}`) { paneKeys.add(`${tab.parentTabId}:${FIRST_PANE_ID}`) } for (const pty of this.ptysById.values()) { if (pty.tabId === tab.parentTabId && pty.paneKey && paneKeys.has(pty.paneKey)) { return pty } } return null } private getMobileTerminalPaneKey(tab: RuntimeMobileSessionTerminalTab): string { if (isTerminalLeafId(tab.leafId)) { return makePaneKey(tab.parentTabId, tab.leafId) } const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null return `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` } private mobileTerminalTabMatchesPty( worktreeId: string, tab: RuntimeMobileSessionTerminalTab, pty: RuntimePtyWorktreeRecord, paneKey = this.getMobileTerminalPaneKey(tab) ): boolean { return pty.worktreeId === worktreeId && pty.tabId === tab.parentTabId && pty.paneKey === paneKey } // Why: group address resolution (Section 4.5) queries per-handle status and must not throw on stale handles; return null on any error. getAgentStatusForHandle(handle: string): string | null { try { const ptyId = this.getTerminalAgentStatusPtyId(handle) return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus } catch { return null } } getAgentStatusOrchestrationContextForPaneKey( paneKey: string ): AgentStatusOrchestrationContext | undefined { const handle = this.getTerminalHandleForPaneKey(paneKey) if (!handle) { return undefined } return this.getAgentStatusOrchestrationContextForHandle(handle) } getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined { return this.getTerminalHandleForPaneKey(paneKey) ?? undefined } getAgentStatusLaunchConfigForPaneKey( paneKey: string, args?: { launchToken?: string } ): SleepingAgentLaunchConfig | undefined { const pty = this.getPtyRecordForPaneKey(paneKey) if (!pty?.launchConfig) { return undefined } if (pty.launchToken === null || pty.launchToken !== args?.launchToken) { return undefined } return copySleepingAgentLaunchConfig(pty.launchConfig) } private buildAgentOrchestrationByPaneKey(): | Record | undefined { const db = this.getOrchestrationDbIfAvailable() if (!db) { return undefined } // Why: this runs on every 16ms graph publish (title/status churn). With no // dispatch rows — the overwhelming majority who never orchestrate — the // per-terminal query fan-out below can only ever yield an empty result, so // skip it wholesale via the DB's cached emptiness probe. Optional call so // partial test-injected DBs without the probe fall through to the scan. if (db.hasAnyDispatchContexts?.() === false) { return undefined } const contexts: Record = {} const queriedHandles = new Set() for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { continue } const handle = this.issueHandle(leaf) queriedHandles.add(handle) const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) if (context) { contexts[this.makeRuntimePaneKey(leaf)] = context } } for (const pty of this.ptysById.values()) { if (!pty.paneKey || contexts[pty.paneKey]) { continue } const handle = this.issuePtyHandle(pty) if (queriedHandles.has(handle)) { continue } queriedHandles.add(handle) const context = this.getAgentStatusOrchestrationContextForHandle(handle, db) if (context) { contexts[pty.paneKey] = context } } return Object.keys(contexts).length > 0 ? contexts : undefined } private getAgentStatusOrchestrationContextForHandle( handle: string, db = this.getOrchestrationDbIfAvailable() ): AgentStatusOrchestrationContext | undefined { // Why: active dispatch is authoritative for reused terminals; settled context stale-groups later work once its row is gone. const dispatch = db?.getActiveDispatchForTerminal?.(handle) ?? this.getRecentSettledDispatchForTerminal(handle, db) if (!dispatch) { return undefined } const task = db?.getTask?.(dispatch.task_id, dispatch.run_id) const display = typeof task?.spec === 'string' ? buildOrchestrationTaskDisplayMetadata({ spec: task.spec, taskTitle: task.task_title, displayName: task.display_name }) : { taskTitle: '', displayName: '' } const owningRun = task?.run_id && task.run_id === dispatch.run_id ? db?.getRun?.(dispatch.run_id) : undefined const runCoordinatorHandle = owningRun?.coordinator_handle ?? undefined const legacyActiveRun = owningRun?.legacy === 1 && (dispatch.status === 'pending' || dispatch.status === 'dispatched') ? db?.getActiveCoordinatorRun?.() : undefined // Why: legacy coordinator runs have no durable task ownership, so fail closed across worktrees. const handleWorktreeId = legacyActiveRun ? this.getWorktreeIdForTerminalHandle(handle) : null const legacyCoordinatorWorktreeId = legacyActiveRun ? this.getWorktreeIdForTerminalHandle(legacyActiveRun.coordinator_handle) : null const scopedLegacyActiveRun = legacyActiveRun && handleWorktreeId && legacyCoordinatorWorktreeId && runtimeWorktreeIdsEqual(legacyCoordinatorWorktreeId, handleWorktreeId) ? legacyActiveRun : undefined const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyActiveRun?.coordinator_handle const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyActiveRun?.id const creatorPaneKey = task?.created_by_pane_key const creatorPaneHandle = creatorPaneKey ? this.getTerminalHandleForPaneKey(creatorPaneKey) : null const creatorAuthority = creatorPaneHandle ? this.getOrchestrationDispatchAuthority(creatorPaneHandle) : null const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null const currentCreatorPane = creatorAuthority?.paneKey ? parsePaneKey(creatorAuthority.paneKey) : null const sameCreatorPane = Boolean( creatorPaneKey && creatorAuthority?.paneKey && (creatorPaneKey === creatorAuthority.paneKey || (storedCreatorPane && currentCreatorPane && storedCreatorPane.leafId === currentCreatorPane.leafId)) ) const paneRun = creatorPaneKey ? db?.getCurrentRunForPane?.(creatorPaneKey) : undefined const sameRunCreatorDispatch = Boolean( task?.creator_dispatch_id && task.creator_dispatch_run_id === owningRun?.id && task.creator_dispatch_pane_key && task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId ) const currentCreatorHandle = owningRun?.legacy === 0 && task?.created_by_run_generation === owningRun.consumer_generation && task.created_by_process_incarnation === creatorAuthority?.processIncarnation && sameCreatorPane && (paneRun ? paneRun.id === owningRun.id && paneRun.consumer_generation === task.created_by_run_generation : sameRunCreatorDispatch) ? (creatorPaneHandle ?? undefined) : undefined const parentTerminalHandle = currentCreatorHandle ?? (coordinatorHandle && coordinatorHandle !== handle ? coordinatorHandle : undefined) const parentPaneKey = parentTerminalHandle ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) : undefined return { taskId: dispatch.task_id, dispatchId: dispatch.id, dispatchStatus: dispatch.status, ...(display.taskTitle ? { taskTitle: display.taskTitle } : {}), ...(display.displayName ? { displayName: display.displayName } : {}), ...(parentTerminalHandle ? { parentTerminalHandle } : {}), ...(parentPaneKey ? { parentPaneKey } : {}), ...(coordinatorHandle ? { coordinatorHandle } : {}), ...(orchestrationRunId ? { orchestrationRunId } : {}) } } private getRecentSettledDispatchForTerminal( handle: string, db = this.getOrchestrationDbIfAvailable() ): ReturnType { const dispatch = db?.getLatestDispatchForTerminal?.(handle) if ( !dispatch?.completed_at || dispatch.status === 'pending' || dispatch.status === 'dispatched' ) { return undefined } const completedAtMs = Date.parse( dispatch.completed_at.includes('T') ? dispatch.completed_at : `${dispatch.completed_at.replace(' ', 'T')}Z` ) if (!Number.isFinite(completedAtMs)) { return undefined } return Date.now() - completedAtMs <= AGENT_STATUS_STALE_AFTER_MS ? dispatch : undefined } private getTerminalHandleForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : undefined if (leaf?.ptyId && leaf.connected) { return this.issueHandle(leaf) } const panePty = this.getPtyRecordForPaneKey(paneKey) if (panePty?.connected) { return this.issuePtyHandle(panePty) } if (leaf?.ptyId) { return this.issueHandle(leaf) } return panePty ? this.issuePtyHandle(panePty) : null } private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { const parsed = parsePaneKey(paneKey) let leafPty: RuntimePtyWorktreeRecord | null = null if (parsed) { const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined if (pty?.connected) { return pty } leafPty = pty ?? null for (const candidate of this.leaves.values()) { if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { continue } const remintedPty = this.ptysById.get(candidate.ptyId) if (remintedPty?.connected) { return remintedPty } leafPty ??= remintedPty ?? null } } let newestMatch: RuntimePtyWorktreeRecord | null = null for (const pty of this.ptysById.values()) { const ptyPane = parsePaneKey(pty.paneKey ?? '') if (pty.paneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { if (pty.connected) { return pty } newestMatch = pty } } return leafPty ?? newestMatch } private getPaneKeyForTerminalHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.paneKey) { return livePty.pty.paneKey } const record = this.handles.get(handle) if (!record || record.runtimeId !== this.runtimeId) { return null } if (!isTerminalLeafId(record.leafId)) { return null } return makePaneKey(record.tabId, record.leafId) } private getWorktreeIdForTerminalHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.worktreeId) { return livePty.pty.worktreeId } const record = this.handles.get(handle) if (!record || record.runtimeId !== this.runtimeId) { return null } return record.worktreeId } private setPtyManagementTitleFromObservedTitle( pty: RuntimePtyWorktreeRecord, title: string | null | undefined, observedAt: number ): void { const trimmed = title?.trim() if (!trimmed) { return } if (isClaudeManagementTitle(trimmed)) { pty.managementTitle = trimmed pty.managementTitleAt = observedAt return } if ( detectAgentStatusFromTitle(trimmed) !== null && observedAt >= (pty.managementTitleAt ?? -1) ) { pty.managementTitle = null pty.managementTitleAt = null } } private nextTitleObservationSequence(): number { this.titleObservationSequence += 1 return this.titleObservationSequence } // Why: title is the tightest agent-presence signal, but a Claude management title is negative evidence for task activity. async isTerminalRunningAgent(handle: string): Promise { try { const pty = this.getLivePtyForHandle(handle) if (pty) { const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) return await this.isPtyRunningAgent(pty.pty, leaf) } const { leaf } = this.getLiveLeafForHandle(handle) // Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix). const paneTitle = getLatestLeafTitle(leaf, null) const paneTitleClassification = classifyAgentTitle(paneTitle) if (paneTitleClassification === 'agent') { return true } const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' if (tabTitleClassification === 'agent') { return true } const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) if (isKnownReadyPromptPreview(waitText)) { return true } const hasCurrentTitleEvidence = paneTitle !== null || tabTitle !== null if (leaf.lastAgentStatus !== null && !hasCurrentTitleEvidence) { return true } if (!leaf.ptyId || !this.ptyController) { return false } const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) if (!fg) { return false } // Why: Claude's management UI runs under the Claude process but isn't a task-capable session; suppress only that process. const shouldSuppressClaudeForeground = paneTitleClassification === 'management' || tabTitleClassification === 'management' if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { return false } // Why: review-note delivery auto-submits with Enter, so only known agent processes are safe (not arbitrary focused TUIs). return await this.isRecognizedForegroundAgentProcess(leaf.ptyId, fg, { suppressClaude: shouldSuppressClaudeForeground }) } catch { return false } } private async isPtyRunningAgent( pty: RuntimePtyWorktreeRecord, leaf: RuntimeLeafRecord | null = null ): Promise { const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } ) : null const leafTitleClassification = classifyAgentTitle(leafTitle) if (leafTitleClassification === 'agent') { return true } const ptyTitle = getLatestAgentCandidateTitle( { title: pty.title, updatedAt: pty.titleUpdatedAt }, { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) const ptyTitleClassification = classifyAgentTitle(ptyTitle) if (leafTitle === null && ptyTitleClassification === 'agent') { return true } const managementTitleClassification = classifyLatestAgentTitle({ title: pty.managementTitle, updatedAt: pty.managementTitleAt }) const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) if (isKnownReadyPromptPreview(waitText)) { return true } // Why: stale status is a fallback only when no current title evidence exists; neutral titles (shells) clear it. if ( pty.lastAgentStatus !== null && leafTitle === null && ptyTitle === null && managementTitleClassification !== 'management' ) { return true } if (!this.ptyController) { return false } const fg = await this.ptyController.getForegroundProcess(pty.ptyId) if (!fg) { return false } const shouldSuppressClaudeForeground = leafTitle !== null ? leafTitleClassification === 'management' : managementTitleClassification === 'management' if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { return false } // Why: review-note delivery auto-submits with Enter, so only known agent processes are safe (not arbitrary focused TUIs). return await this.isRecognizedForegroundAgentProcess(pty.ptyId, fg, { suppressClaude: shouldSuppressClaudeForeground }) } private async isRecognizedForegroundAgentProcess( ptyId: string, foregroundProcess: string, options: { suppressClaude?: boolean } = {} ): Promise { const initialRecognition = recognizeAgentProcess(foregroundProcess) if (initialRecognition !== null) { return !( options.suppressClaude === true && isExpectedAgentProcess(initialRecognition.processName, 'claude') ) } if (!this.isAgentWrapperForegroundProcess(foregroundProcess) || !this.ptyController) { return false } const startedAt = Date.now() while (Date.now() - startedAt < FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS) { await new Promise((resolve) => setTimeout(resolve, FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS) ) const refreshedProcess = await this.ptyController.getForegroundProcess(ptyId) const refreshedRecognition = recognizeAgentProcess(refreshedProcess) if (refreshedRecognition !== null) { return !( options.suppressClaude === true && isExpectedAgentProcess(refreshedRecognition.processName, 'claude') ) } if (!refreshedProcess || !this.isAgentWrapperForegroundProcess(refreshedProcess)) { return false } } return false } private isAgentWrapperForegroundProcess(processName: string): boolean { // Why: daemon/SSH PTYs can report the interpreter before the async cmdline cache resolves; retry only known wrappers. return isAgentForegroundWrapperProcess(processName) } private getPrimaryLeafForPty(ptyId: string): RuntimeLeafRecord | null { return this.getLeavesForPty(ptyId)[0] ?? null } deliverPendingMessagesForHandle(handle: string): void { try { const { leaf } = this.getLiveLeafForHandle(handle) if (leaf.lastAgentStatus === 'idle') { this.deliverPendingMessages(leaf) } } catch { // Unknown/stale handles can't be pushed now; the persisted message stays available via explicit check or future idle delivery. } } // Why: wake blocking orchestration.check --wait calls on this handle so they return the new message immediately instead of polling. notifyMessageArrived(handle: string, messageType?: string): void { const waiters = this.messageWaitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { // Why: don't wake a coordinator waiting for worker_done/escalation on heartbeat noise it would misread as idleness. if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { continue } this.resolveMessageWaiter(waiter, 'notified') } } waitForMessage( handle: string, options?: { typeFilter?: string[] timeoutMs?: number signal?: AbortSignal exclusive?: boolean } ): Promise { return new Promise((resolve) => { const currentWaiters = this.messageWaitersByHandle.get(handle) if (options?.exclusive && currentWaiters && currentWaiters.size > 0) { resolve('waiter_exists') return } const timeoutMs = options?.timeoutMs ?? ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS const waiter: MessageWaiter = { handle, typeFilter: options?.typeFilter, resolve, timeout: null, abortCleanup: null } // Why: on caller abort (RPC socket closed — design doc §3.1), resolve now to release the long-poll slot instead of waiting out timeoutMs. const signal = options?.signal const onAbort = (): void => { this.removeMessageWaiter(waiter) resolve('cancelled') } if (signal) { if (signal.aborted) { resolve('cancelled') return } waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) signal.addEventListener('abort', onAbort, { once: true }) } waiter.timeout = setTimeout(() => { this.removeMessageWaiter(waiter) resolve('timed_out') }, timeoutMs) let waiters = this.messageWaitersByHandle.get(handle) if (!waiters) { waiters = new Set() this.messageWaitersByHandle.set(handle, waiters) } waiters.add(waiter) }) } cancelMessageWaiters(handle: string): void { const waiters = this.messageWaitersByHandle.get(handle) if (!waiters) { return } for (const waiter of [...waiters]) { this.resolveMessageWaiter(waiter, 'cancelled') } } private resolveMessageWaiter(waiter: MessageWaiter, result: MessageWaitResult): void { this.removeMessageWaiter(waiter) waiter.resolve(result) } private removeMessageWaiter(waiter: MessageWaiter): void { if (waiter.timeout) { clearTimeout(waiter.timeout) waiter.timeout = null } if (waiter.abortCleanup) { waiter.abortCleanup() waiter.abortCleanup = null } const waiters = this.messageWaitersByHandle.get(waiter.handle) if (waiters) { waiters.delete(waiter) if (waiters.size === 0) { this.messageWaitersByHandle.delete(waiter.handle) } } } private buildPtyTerminalSummary( pty: RuntimePtyWorktreeRecord, worktreesById: Map ): RuntimeTerminalSummary { const worktree = worktreesById.get(pty.worktreeId) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !pty.tabId || !pane || pane.tabId !== pty.tabId return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, incarnationId: pty.incarnationId, orphaned, worktreeId: pty.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', tabId: orphaned ? `pty:${pty.ptyId}` : pty.tabId!, leafId: orphaned ? `pty:${pty.ptyId}` : pane.leafId, title: getLatestPtyTitle(pty), connected: pty.connected, writable: pty.connected, lastOutputAt: pty.lastOutputAt, preview: pty.preview } } private getLiveLeafForHandle(handle: string): { record: TerminalHandleRecord leaf: RuntimeLeafRecord } { this.assertGraphReady() const record = this.handles.get(handle) if (!record || record.runtimeId !== this.runtimeId) { throw new Error('terminal_handle_stale') } if (record.rendererGraphEpoch !== this.rendererGraphEpoch) { throw new Error('terminal_handle_stale') } const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) { throw new Error('terminal_handle_stale') } return { record, leaf } } private getLivePtyForHandle(handle: string): { record: TerminalHandleRecord pty: RuntimePtyWorktreeRecord } | null { let record = this.handles.get(handle) if (!record) { const ptyId = [...this.handleByPtyId.entries()].find( ([, mappedHandle]) => mappedHandle === handle )?.[0] const pty = ptyId ? this.ptysById.get(ptyId) : null if (pty) { // Why: graph reload clears renderer handle records, but runtime-owned PTY handles remain the caller's control identity. this.issuePtyHandle(pty) record = this.handles.get(handle) } } if (!record || record.runtimeId !== this.runtimeId || !record.tabId.startsWith('pty:')) { return null } if (!record.ptyId) { return null } const pty = this.ptysById.get(record.ptyId) if (!pty || pty.ptyId !== record.ptyId) { return null } // Why: renderer adoption can race with CLI reads; keep ptyId → handle populated so summaries don't mint a second handle for the same terminal. this.handleByPtyId.set(record.ptyId, handle) return { record, pty } } private assertLiveTerminalHandleTargetsPty(handle: string, expectedPtyId: string): void { const runtimePty = this.getLivePtyForHandle(handle) if (runtimePty) { if (runtimePty.pty.ptyId !== expectedPtyId) { throw new Error('terminal_handle_stale') } return } const { leaf } = this.getLiveLeafForHandle(handle) if (leaf.ptyId !== expectedPtyId) { throw new Error('terminal_handle_stale') } } private readPtyTerminal( handle: string, pty: RuntimePtyWorktreeRecord, opts: { cursor?: number; limit?: number } = {} ): RuntimeTerminalRead { return readTerminalTail({ handle, status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', previewLines: pty.tailBuffer, completedLines: pty.tailTranscriptBuffer, partialLine: pty.tailPartialLine, completedLineCount: pty.tailLinesTotal, bufferTruncated: pty.tailTruncated, cursor: opts.cursor, limit: opts.limit }) } private issueHandle(leaf: RuntimeLeafRecord): string { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existingHandle = this.handleByLeafKey.get(leafKey) if (existingHandle) { const existingRecord = this.handles.get(existingHandle) if ( existingRecord && existingRecord.rendererGraphEpoch === this.rendererGraphEpoch && existingRecord.ptyId === leaf.ptyId && existingRecord.ptyGeneration === leaf.ptyGeneration ) { return existingHandle } } const preAllocatedHandle = this.adoptPreAllocatedHandle(leaf) const handle = preAllocatedHandle ?? `term_${randomUUID()}` if (!preAllocatedHandle) { this.syntheticTerminalHandles.add(handle) } if (this.handles.has(handle)) { return handle } this.handles.set(handle, { handle, runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, worktreeId: leaf.worktreeId, tabId: leaf.tabId, leafId: leaf.leafId, ptyId: leaf.ptyId, ptyGeneration: leaf.ptyGeneration }) this.handleByLeafKey.set(leafKey, handle) return handle } private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null { if (!leaf.ptyId) { return null } const preAllocated = this.handleByPtyId.get(leaf.ptyId) if (!preAllocated) { return null } const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) this.handles.set(preAllocated, { handle: preAllocated, runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, worktreeId: leaf.worktreeId, tabId: leaf.tabId, leafId: leaf.leafId, ptyId: leaf.ptyId, ptyGeneration: leaf.ptyGeneration }) this.handleByLeafKey.set(leafKey, preAllocated) return preAllocated } private issuePtyHandle(pty: RuntimePtyWorktreeRecord): string { const existingHandle = this.handleByPtyId.get(pty.ptyId) ?? this.findHandleForPtyRecord(pty.ptyId) if (existingHandle) { const existingRecord = this.handles.get(existingHandle) if ( existingRecord && existingRecord.runtimeId === this.runtimeId && existingRecord.ptyId === pty.ptyId ) { this.handleByPtyId.set(pty.ptyId, existingHandle) return existingHandle } } const handle = existingHandle ?? `term_${randomUUID()}` if (!existingHandle) { this.syntheticTerminalHandles.add(handle) } const syntheticId = `pty:${pty.ptyId}` this.handles.set(handle, { handle, runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, worktreeId: pty.worktreeId, tabId: syntheticId, leafId: syntheticId, ptyId: pty.ptyId, ptyGeneration: 0 }) this.handleByPtyId.set(pty.ptyId, handle) return handle } private findHandleForPtyRecord(ptyId: string): string | null { for (const [handle, record] of this.handles) { if ( record.runtimeId === this.runtimeId && record.ptyId === ptyId && record.tabId.startsWith('pty:') ) { return handle } } return null } private refreshWritableFlags(): void { for (const leaf of this.leaves.values()) { leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null } } private invalidateLeafHandle(leafKey: string): void { const handle = this.handleByLeafKey.get(leafKey) if (!handle) { return } this.handleByLeafKey.delete(leafKey) this.handles.delete(handle) this.syntheticTerminalHandles.delete(handle) this.rejectWaitersForHandle(handle, 'terminal_handle_stale') } private adoptFirstPtyForLeafHandle( leafKey: string, ptyId: string | null, ptyGeneration: number ): boolean { const handle = this.handleByLeafKey.get(leafKey) const record = handle ? this.handles.get(handle) : null if (!handle || !record || record.ptyId !== null || ptyId === null) { return false } this.handles.set(handle, { ...record, ptyId, ptyGeneration }) return true } private rememberDetachedPreAllocatedLeaves(): void { for (const leaf of this.leaves.values()) { if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) { // Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control survives renderer graph loss while the PTY is alive. this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf) } } } private resolveExitWaiters(leaf: RuntimeLeafRecord): void { const handle = this.issueHandle(leaf) if (!handle) { return } const waiters = this.waitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { if (waiter.condition === 'exit') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'exit', leaf)) } else { // Why: after exit, conditions like tui-idle can never be satisfied — reject now instead of spinning the poll until timeout on a dead process. this.removeWaiter(waiter) waiter.reject(new Error('terminal_exited')) } } } private resolveTuiIdleWaiters(leaf: RuntimeLeafRecord): void { const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (!handle) { return } const waiters = this.waitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf)) } } } private resolvePtyExitWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { const handle = this.handleByPtyId.get(ptyId) if (!handle) { return } const waiters = this.waitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { if (waiter.condition === 'exit') { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'exit', pty)) } else { this.removeWaiter(waiter) waiter.reject(new Error('terminal_exited')) } } } private resolvePtyTuiIdleWaiters(pty: RuntimePtyWorktreeRecord, ptyId: string): void { const handle = this.handleByPtyId.get(ptyId) if (!handle) { return } const waiters = this.waitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { if (waiter.condition === 'tui-idle') { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, 'tui-idle', pty)) } } } // Why: the primary OSC-title signal can't fire for daemon-hosted terminals (no PTY data through the runtime), so this fallback polls the renderer-synced tab title + foreground-process quiescence; self-cancels when the OSC path fires. private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void { let foregroundPollInFlight = false waiter.pollInterval = setInterval(async () => { if (!waiter.pollInterval) { return } let startedForegroundPoll = false try { if (leaf.lastAgentStatus === 'idle') { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) return } // Why: the renderer-synced title is the only path where OSC titles are visible for daemon-hosted terminals. const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title if (pollTitle) { const titleStatus = detectExplicitIdleStatusFromTitle(pollTitle) if (titleStatus === 'idle') { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) return } } const leafWaitText = buildTerminalWaitText( leaf.tailBuffer, leaf.tailPartialLine, leaf.preview ) const blockedReason = detectTerminalWaitBlockedReason(leafWaitText) if (blockedReason) { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter( waiter, buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason) ) return } if (isKnownReadyPromptPreview(leafWaitText)) { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) return } // Foreground fallback: a reported non-shell process with quiet output is treated as idle. if ( leaf.lastAgentStatus === null && leaf.ptyId && this.ptyController && !foregroundPollInFlight ) { foregroundPollInFlight = true startedForegroundPoll = true const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) if (fg && !isShellProcess(fg)) { const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0 if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) } } } } catch { // Swallow transient PTY inspection errors and keep polling. } finally { if (startedForegroundPoll) { foregroundPollInFlight = false } } }, TUI_IDLE_POLL_INTERVAL_MS) } private startPtyTuiIdleFallbackPoll(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void { let foregroundPollInFlight = false waiter.pollInterval = setInterval(async () => { if (!waiter.pollInterval) { return } let startedForegroundPoll = false try { if (pty.lastAgentStatus === 'idle') { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) return } const ptyWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) const blockedReason = detectTerminalWaitBlockedReason(ptyWaitText) if (blockedReason) { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter( waiter, buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason) ) return } // Why: adopted background PTY handles use their live xterm title as the same readiness signal as leaf handles. if ( this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || isKnownReadyPromptPreview(ptyWaitText) ) { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) return } if (pty.lastAgentStatus === null && this.ptyController && !foregroundPollInFlight) { foregroundPollInFlight = true startedForegroundPoll = true const fg = await this.ptyController.getForegroundProcess(pty.ptyId) if (fg && !isShellProcess(fg)) { const quietMs = pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0 if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) } } } } catch { // Swallow transient PTY inspection errors and keep polling. } finally { if (startedForegroundPoll) { foregroundPollInFlight = false } } }, TUI_IDLE_POLL_INTERVAL_MS) } private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { for (const leaf of this.leaves.values()) { if (leaf.ptyId !== pty.ptyId) { continue } const title = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title if (!title) { continue } const status = detectExplicitIdleStatusFromTitle(title) if (status !== null) { return status } } return null } // Why: delivered_at for Claude targets stamps only in the delayed-Enter // callback, so the whole write→settle span must be single-flight per pty — // a second read inside it would re-inject the same unread rows. Triggers // landing mid-flight park the latest leaf and re-run once on settle. The // flight object is the settle identity: a stale settle surviving an exit // retire must not clear a newer same-id flight or flush its parked trigger. private readonly messageDeliveryFlightsByPtyId = new Map< string, { enterTimer: ReturnType | null } >() private readonly parkedMessageRedeliveryLeavesByPtyId = new Map() private settlePendingMessageDelivery( ptyId: string, flight: { enterTimer: ReturnType | null } ): void { if (this.messageDeliveryFlightsByPtyId.get(ptyId) !== flight) { return } this.messageDeliveryFlightsByPtyId.delete(ptyId) const parkedLeaf = this.parkedMessageRedeliveryLeavesByPtyId.get(ptyId) if (!parkedLeaf) { return } this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) this.deliverPendingMessages(parkedLeaf) } // Why: an Enter armed for a dead session must not fire into a same-id cold // restore — it would inject \r and stamp rows the replacement never saw. // Retire without stamping; the rows re-deliver on the replacement's next idle. private retirePendingMessageDeliveryForPty(ptyId: string): void { const flight = this.messageDeliveryFlightsByPtyId.get(ptyId) if (flight?.enterTimer != null) { clearTimeout(flight.enterTimer) } this.messageDeliveryFlightsByPtyId.delete(ptyId) this.parkedMessageRedeliveryLeavesByPtyId.delete(ptyId) } // Why: push-on-idle delivery is event-driven (no polling) because the runtime owns both the message store and terminal status detection. private deliverPendingMessages(leaf: RuntimeLeafRecord, skipAbsenceProbe = false): void { if (!this._orchestrationDb) { return } const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (!handle) { return } // Why before reading rows: rows read mid-flight are the not-yet-stamped ones. if (leaf.ptyId && this.messageDeliveryFlightsByPtyId.has(leaf.ptyId)) { this.parkedMessageRedeliveryLeavesByPtyId.set(leaf.ptyId, leaf) return } const unread = this._orchestrationDb.getUndeliveredUnreadMessages(handle) if (unread.length === 0) { return } if (!leaf.writable || !leaf.ptyId) { return } if ( !skipAbsenceProbe && this.ptyController?.probePtyLiveness && !this.controllerKnowsPtyIsLive(leaf.ptyId) ) { // Why: a fire-and-forget write to a prior process's ptyId reports success // and would mark these delivered while losing them. Proven absence keeps // them queued for a future surface; unknown liveness still delivers. const probedPtyId = leaf.ptyId // Why: triggers arriving mid-probe must not each arm a continuation — the // Claude Enter delay stamps delivered_at late, so every continuation would // re-read the same unread rows and double-deliver. The single armed // continuation re-reads fresh rows when it fires, so nothing is lost. if (this.probeDeferredDeliveryPtyIds.has(probedPtyId)) { return } this.probeDeferredDeliveryPtyIds.add(probedPtyId) void this.isLeafPtyProvenAbsent(probedPtyId) .then((absent) => { this.probeDeferredDeliveryPtyIds.delete(probedPtyId) if (!absent && leaf.ptyId === probedPtyId) { this.deliverPendingMessages(leaf, true) } }) .catch(() => { this.probeDeferredDeliveryPtyIds.delete(probedPtyId) }) return } const deliveryPtyId = leaf.ptyId const flight: { enterTimer: ReturnType | null } = { enterTimer: null } this.messageDeliveryFlightsByPtyId.set(deliveryPtyId, flight) // Why: every sync outcome — failed write, sync-stamped branch, or a throw — // must end the flight here, or a leaked flag parks this pty's deliveries // forever. Only an armed Enter hands settling to its own callback. let settlesInEnterCallback = false try { const payload = formatMessagesForInjection(unread) const wrote = this.ptyController?.write(deliveryPtyId, payload) ?? false if (!wrote) { return } // The active coordinator prompt is user-owned input, so push-on-idle must not synthesize Enter. if (this._orchestrationDb.getActiveCoordinatorRun()?.coordinator_handle === handle) { this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) return } const tabTitle = this.tabs.get(leaf.tabId)?.title if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { // Why: Cursor Agent treats injected PTY text as editable prompt input, so submitting must stay under user control. this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) return } // Why: Claude Code treats a large PTY write as a paste and swallows a \r in the same write; send Enter separately after a delay, stamping delivered_at only once \r is confirmed. // Important (design doc §3.2, feedback #2): stamp delivered_at, not read — read means "a check-caller consumed this"; flipping it would hide the message from check --unread. flight.enterTimer = setTimeout(() => { try { // Why current state, not the closure: graph resync replaces leaf // objects, so the captured record can read writable=true after the // pty died, and an exit retire may have superseded this flight. if (this.messageDeliveryFlightsByPtyId.get(deliveryPtyId) !== flight) { return } const currentLeaf = this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (!currentLeaf || currentLeaf.ptyId !== deliveryPtyId || !currentLeaf.writable) { return } const submitted = this.ptyController?.write(deliveryPtyId, '\r') ?? false if (submitted) { this._orchestrationDb?.markAsDelivered(unread.map((m) => m.id)) } } catch { // Terminal may have closed during the delay — messages stay queued (delivered_at NULL) and re-deliver on next idle. } finally { // Why finally: every outcome — submit, refusal, throw — ends the flight, // and settle re-runs any trigger parked during it so nothing strands. this.settlePendingMessageDelivery(deliveryPtyId, flight) } }, 500) settlesInEnterCallback = true } finally { if (!settlesInEnterCallback) { this.settlePendingMessageDelivery(deliveryPtyId, flight) } } } private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void { this.removeWaiter(waiter) waiter.resolve(result) } private bindTerminalWaiterAbort( waiter: TerminalWaiter, signal: AbortSignal | undefined ): boolean { if (!signal) { return true } if (signal.aborted) { return false } const onAbort = (): void => { this.removeWaiter(waiter) waiter.reject(new Error('request_aborted')) } waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) signal.addEventListener('abort', onAbort, { once: true }) return true } private rejectWaitersForHandle(handle: string, code: string): void { const waiters = this.waitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { this.removeWaiter(waiter) waiter.reject(new Error(code)) } } private rejectAllWaiters(code: string): void { for (const handle of [...this.waitersByHandle.keys()]) { this.rejectWaitersForHandle(handle, code) } } private removeWaiter(waiter: TerminalWaiter): void { if (waiter.timeout) { clearTimeout(waiter.timeout) } if (waiter.pollInterval) { clearInterval(waiter.pollInterval) } if (waiter.abortCleanup) { waiter.abortCleanup() waiter.abortCleanup = null } const waiters = this.waitersByHandle.get(waiter.handle) if (!waiters) { return } waiters.delete(waiter) if (waiters.size === 0) { this.waitersByHandle.delete(waiter.handle) } } private getLeafKey(tabId: string, leafId: string): string { return `${tabId}::${leafId}` } // ── Linear integration ── linearConnect(apiKey: string): ReturnType { return connectLinear(apiKey) } linearDisconnect(workspaceId?: string): { ok: true } { disconnectLinear(workspaceId) return { ok: true } } linearSelectWorkspace(workspaceId: LinearWorkspaceSelection): ReturnType { return selectLinearWorkspace(workspaceId) } linearStatus(): ReturnType { return getLinearStatus() } linearTestConnection(workspaceId?: string): ReturnType { return testLinearConnection(workspaceId) } linearSearchIssues( query: string, limit = 20, workspaceId?: LinearWorkspaceSelection ): ReturnType { return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId) } linearSearchForAgents(args: { query: string limit?: number workspaceId?: string | 'all' }): ReturnType { return searchLinearIssuesForAgents(args) } linearIssueContext(request: LinearIssueRequest): ReturnType { return readLinearIssueContext(request, (context) => this.linearResolveCurrentIssue(context)) } async linearTeamListForAgents(params: { workspaceId?: string | 'all' }): Promise { try { const result = await listLinearTeamsForAgent(params.workspaceId) const workspaceErrors = result.errors.map((error) => ({ workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, code: this.linearWorkspaceErrorCode(error.type), message: sanitizeLinearErrorMessage(error.message) })) return { teams: result.teams.map((team) => this.linearTeamSummary(team)), meta: { workspaceId: params.workspaceId, returned: result.teams.length, partial: workspaceErrors.length > 0, workspaceErrors } } } catch (error) { throw this.mapLinearReadFailure(error) } } async linearTeamMembersForAgents(params: { teamInput: string workspaceId?: string }): Promise { const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) try { const members = await getLinearTeamMembersOrThrow(team.id, team.workspaceId) return { team: this.linearTeamSummary(team), members: members.map((member) => ({ id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl })), meta: { workspaceId: team.workspaceId, returned: members.length } } } catch (error) { throw this.mapLinearReadFailure(error) } } async linearTeamStatesForAgents(params: { teamInput: string workspaceId?: string }): Promise { const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) return { team: this.linearTeamSummary(team), states: states.map((state) => ({ id: state.id, name: state.name, type: state.type, color: state.color, position: state.position })), meta: { workspaceId: team.workspaceId, returned: states.length } } } async linearTeamLabelsForAgents(params: { teamInput: string workspaceId?: string }): Promise { const team = await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) const labels = await this.getLinearTeamLabelsForWrite(team.id, team.workspaceId) return { team: this.linearTeamSummary(team), labels: labels.map((label) => ({ id: label.id, name: label.name, color: label.color })), meta: { workspaceId: team.workspaceId, returned: labels.length } } } async linearProjectListForAgents(params: { query?: string limit?: number workspaceId?: string | 'all' }): Promise { const limit = clampLinearSearchLimit(params.limit) try { const result = await this.linearListProjects(params.query, limit, params.workspaceId, true) const projects = result.items.slice(0, limit).map((project) => ({ id: project.id, name: project.name, ...(project.url ? { url: project.url } : {}), ...(project.workspaceId ? { workspaceId: project.workspaceId } : {}), ...(project.workspaceName ? { workspaceName: project.workspaceName } : {}), ...(project.teams ? { teams: project.teams } : {}) })) const workspaceErrors = (result.errors ?? []).map((error) => ({ workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, code: this.linearWorkspaceErrorCode(error.type), message: sanitizeLinearErrorMessage(error.message) })) return { projects, meta: { query: params.query, workspaceId: params.workspaceId, limit, returned: projects.length, hasMore: result.hasMore === true || result.items.length > limit, partial: workspaceErrors.length > 0, workspaceErrors } } } catch (error) { throw this.mapLinearReadFailure(error) } } async linearIssueListForAgents(params: { filter?: LinearIssueListFilter teamInput?: string limit?: number workspaceId?: string | 'all' }): Promise { const filter = params.filter ?? 'assigned' const limit = clampLinearIssueListLimit(params.limit) const team = params.teamInput ? await this.resolveLinearTeamInput(params.teamInput, params.workspaceId) : null const workspaceId = team?.workspaceId ?? params.workspaceId try { const result = await listLinearIssues(filter, limit, workspaceId, { teamId: team?.id }) return { issues: result.items.map((issue) => ({ id: issue.id, identifier: issue.identifier, title: issue.title, url: issue.url, state: issue.state, team: issue.team, project: issue.project ?? null, assignee: issue.assignee ?? null, priority: issue.priority, estimate: issue.estimate, dueDate: issue.dueDate, updatedAt: issue.updatedAt, workspace: { id: issue.workspaceId ?? workspaceId ?? '', name: issue.workspaceName ?? issue.workspaceId ?? workspaceId ?? '' } })), meta: { filter, workspaceId, ...(team ? { team: this.linearTeamSummary(team) } : {}), limit, returned: result.items.length, hasMore: result.hasMore === true, partial: (result.errors?.length ?? 0) > 0, workspaceErrors: (result.errors ?? []).map((error) => ({ workspace: { id: error.workspaceId, name: error.workspaceName ?? error.workspaceId }, code: this.linearWorkspaceErrorCode(error.type), message: sanitizeLinearErrorMessage(error.message) })) } } } catch (error) { throw this.mapLinearReadFailure(error) } } async linearMcpIssueList(params: LinearMcpIssueListRequest): Promise { try { return await listMcpIssues(params) } catch (error) { throw this.mapLinearReadFailure(error) } } async linearResolveCurrentIssue( context?: LinearCurrentIssueContextHints ): Promise> { if (!this.store) { throw new Error('runtime_unavailable') } let worktree: ResolvedWorktree | null = null if (context?.terminalHandle) { try { const terminal = await this.showTerminal(context.terminalHandle) if (context.worktreeId && context.worktreeId !== terminal.worktreeId) { throw new LinearAgentAccessError( 'linear_permission_denied', 'The provided Linear worktree context does not match the caller terminal.' ) } worktree = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) } catch (error) { if (error instanceof LinearAgentAccessError) { throw error } if (context.remote === true || context.worktreeId) { throw new LinearAgentAccessError( 'linear_issue_required', 'Could not verify the current Linear-linked worktree.' ) } } } if (!worktree && context?.remote !== true && context?.cwd) { worktree = await this.resolveWorktreeForContainedPath(context.cwd) if (!worktree) { throw new LinearAgentAccessError( 'linear_issue_required', 'Run --current from inside an Orca-managed worktree or pass an issue id.' ) } } if (!worktree) { throw new LinearAgentAccessError( 'linear_issue_required', 'Run --current from inside an Orca-managed worktree or pass an issue id.' ) } const link = getLinearCurrentIssueFromWorktree(worktree) if (!link.workspaceId) { const backfill = resolveLegacyLinearLinkWorkspace( worktree.linkedLinearIssue ?? '', worktree.linkedLinearIssueOrganizationUrlKey ) if (backfill?.workspaceId) { this.store.setWorktreeMeta(worktree.id, { linkedLinearIssueWorkspaceId: backfill.workspaceId, linkedLinearIssueOrganizationUrlKey: backfill.organizationUrlKey ?? null }) return { ...link, workspaceId: backfill.workspaceId, organizationUrlKey: backfill.organizationUrlKey ?? link.organizationUrlKey, backfill } } } return link } private async resolveWorktreeForContainedPath(cwd: string): Promise { const currentPath = resolve(cwd) let best: ResolvedWorktree | null = null for (const candidate of await this.listResolvedWorktrees()) { if (!isPathInsideOrEqual(candidate.path, currentPath)) { continue } if (!best || candidate.path.length > best.path.length) { best = candidate } } return best } linearListIssues( filter?: LinearListFilter, limit = 20, workspaceId?: LinearWorkspaceSelection, options?: LinearIssueListOptions ): ReturnType { return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId, options) } linearCreateIssue( teamId: string, title: string, description?: string, workspaceId?: string, parentIssueId?: string, projectId?: string | null, options?: { stateId?: string priority?: number estimate?: number | null dueDate?: string | null assigneeId?: string | null labelIds?: string[] } ): ReturnType { return createLinearIssue(teamId, title, description, workspaceId, { parentId: parentIssueId, projectId, ...options }) } linearGetIssue(id: string, workspaceId?: string): ReturnType { return getLinearIssue(id, workspaceId) } linearUpdateIssue( id: string, updates: LinearIssueUpdate, workspaceId?: string ): ReturnType { return updateLinearIssue(id, updates, workspaceId) } linearAddIssueComment( issueId: string, body: string, workspaceId?: string ): ReturnType { return addLinearIssueComment(issueId, body, workspaceId) } async linearIssueSetState(params: { input?: string current?: boolean workspaceId?: string to: string context?: LinearCurrentIssueContextHints }): Promise { const target = await this.resolveLinearAgentWriteTarget(params) const teamId = target.issue.team?.id if (!teamId) { throw linearError('linear_invalid_state', 'The Linear issue does not have a team.') } const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId) const state = this.resolveLinearAgentState(params.to, states) if (!state) { throw linearError( 'linear_invalid_state', `No workflow state exactly matched "${params.to}".`, { states: states.map(({ id, name, type }) => ({ id, name, type })), nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`] } ) } const previousState = target.issue.state?.id && target.issue.state.name ? { id: target.issue.state.id, name: target.issue.state.name } : null const alreadyInState = target.issue.state?.id === state.id if (!alreadyInState) { await this.runLinearAgentWrite( async (signal) => { const updated = await updateLinearIssueForAgent( target.issue.id, { stateId: state.id }, target.workspaceId, { signal } ) if (updated.state?.id !== state.id) { throw new LinearWriteFailure( 'unconfirmed', 'Linear state update could not be confirmed.' ) } return updated }, (cause) => linearError( 'linear_write_unconfirmed', 'Linear may have applied the state change, but Orca could not confirm it.', { nextSteps: [ `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.` ], ...(cause ? { cause } : {}) } ) ) } await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return { issue: this.linearWriteIssueRef(target.issue), state: { id: state.id, name: state.name, type: state.type }, previousState, meta: { workspaceId: target.workspaceId, alreadyInState } } } async linearIssueRelationWrite( params: LinearIssueRelationWriteRequest ): Promise { const target = await this.resolveLinearAgentWriteTarget(params) const related = await this.resolveLinearAgentWriteTarget({ input: params.relatedInput, workspaceId: target.workspaceId, context: params.context }) if (target.issue.id === related.issue.id) { throw linearError('linear_write_failed', 'An issue cannot be related to itself.') } try { const result = await this.runLinearAgentWrite( (signal) => writeIssueRelation({ issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title }, relatedIssue: { ...this.linearWriteIssueRef(related.issue), title: related.issue.title }, relationship: params.relationship, operation: params.operation, workspaceId: target.workspaceId, signal }), (cause) => linearError( 'linear_write_unconfirmed', 'Linear may have applied the relation change, but Orca could not confirm it.', { nextSteps: [ `Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.` ], ...(cause ? { cause } : {}) } ) ) await this.notifyLinearLinkedIssueUpdated(target.workspaceId, [ target.issue.identifier, related.issue.identifier ]) return result } catch (error) { throw this.mapLinearReadFailure(error) } } async linearSaveIssue(params: LinearSaveIssueRequest): Promise { if ((params.description?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { throw linearError('linear_body_too_large', 'Linear issue body is too large.') } if (!params.input && !params.current) { if (!params.title || !params.team) { throw linearError( 'linear_write_failed', 'Creating with save-issue requires both team and title.' ) } const created = await this.linearIssueCreate({ title: params.title, body: params.description, teamInput: params.team, state: params.state, assignee: params.assignee ?? undefined, priority: params.priority, estimate: params.estimate ?? undefined, dueDate: params.dueDate ?? undefined, labels: params.labels, projectInput: params.project ?? undefined, parentInput: params.parentId ?? undefined, workspaceId: params.workspaceId, writeId: params.writeId, context: params.context }) return { ...created, meta: { ...created.meta, created: true } } } if (params.team !== undefined) { throw linearError('linear_write_failed', 'Team can only be set when creating an issue.') } const target = await this.resolveLinearAgentWriteTarget(params) const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId) if (Object.keys(fields).length === 0) { throw linearError('linear_write_failed', 'No issue fields were provided to save.') } const alreadySet = this.linearSavedIssueMatchesIntent(current, fields) const updated = alreadySet ? current : await this.runLinearAgentWrite( async (signal) => { const saved = await updateLinearIssueForAgent( target.issue.id, fields, target.workspaceId, { signal } ) if (!this.linearSavedIssueMatchesIntent(saved, fields)) { throw new LinearWriteFailure( 'unconfirmed', 'Linear issue save could not be confirmed.' ) } return saved }, (cause) => linearError( 'linear_write_unconfirmed', 'Linear may have applied the issue save, but Orca could not confirm it.', { nextSteps: [ `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.` ], ...(cause ? { cause } : {}) } ) ) await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return { issue: updated, meta: { workspaceId: target.workspaceId, created: false } } } async linearIssueUpdateTask( params: LinearIssueTaskUpdateRequest ): Promise { const target = await this.resolveLinearAgentWriteTarget(params) const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId) if (!update) { throw linearError('linear_write_failed', 'No Linear task field update was requested.') } const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update) if (!alreadySet) { await this.runLinearAgentWrite( async (signal) => { const updated = await updateLinearIssueForAgent( target.issue.id, update.fields, target.workspaceId, { signal } ) if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) { throw new LinearWriteFailure( 'unconfirmed', 'Linear task field update could not be confirmed.' ) } return updated }, (cause) => linearError( 'linear_write_unconfirmed', 'Linear may have applied the task update, but Orca could not confirm it.', { nextSteps: [ `Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.` ], ...(cause ? { cause } : {}) } ) ) } await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) const finalRecord = alreadySet ? current : await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId) return this.linearTaskUpdateResult( params.operation, target.issue, target.workspaceId, current, finalRecord, alreadySet ) } async linearIssueAddComment(params: { input?: string current?: boolean workspaceId?: string body: string replyTo?: string writeId?: string context?: LinearCurrentIssueContextHints }): Promise { if (params.body.length > LINEAR_WRITE_BODY_CAP) { throw linearError('linear_body_too_large', 'Linear comment body is too large.') } const target = await this.resolveLinearAgentWriteTarget(params) const parentId = params.replyTo ? await this.resolveLinearCommentParentId(target.issue.id, params.replyTo, target.workspaceId) : null const writeId = params.writeId ?? randomUUID() const existing = params.writeId !== undefined ? await this.getMatchingLinearCommentWrite( writeId, target.issue.id, parentId, target.workspaceId, true ) : null if (existing) { await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearCommentResult(existing, target, params.body.length, writeId, true) } try { const comment = await this.runLinearAgentWrite( (signal) => addLinearIssueCommentForAgent(target.issue.id, params.body, target.workspaceId, { id: writeId, parentId, signal }), (cause) => this.linearCreateStyleUnconfirmed('comment', writeId, target, { parentId, bodyRequired: true, cause }) ) await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearCommentResult(comment, target, params.body.length, writeId, false) } catch (error) { if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { const comment = await this.refetchLinearCommentAfterDuplicate( writeId, target.issue.id, parentId, target.workspaceId, () => this.linearCreateStyleUnconfirmed('comment', writeId, target, { parentId, bodyRequired: true }) ) await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearCommentResult(comment, target, params.body.length, writeId, true) } throw error } } async linearIssueAttachLink(params: { input?: string current?: boolean workspaceId?: string url: string title?: string writeId?: string context?: LinearCurrentIssueContextHints }): Promise { const url = this.parseLinearAttachmentUrl(params.url) const target = await this.resolveLinearAgentWriteTarget(params) const writeId = params.writeId ?? randomUUID() const title = params.title?.trim() || this.defaultLinearAttachmentTitle(url) const existing = params.writeId !== undefined ? await this.getMatchingLinearAttachmentWrite( writeId, target.issue.id, target.workspaceId, true ) : null if (existing) { await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearAttachResult(existing, target, writeId, true) } try { const attachment = await this.runLinearAgentWrite( (signal) => createLinearIssueAttachment( target.issue.id, { id: writeId, title, url: url.toString() }, target.workspaceId, { signal } ), (cause) => this.linearCreateStyleUnconfirmed('attach', writeId, target, { title, url: url.toString(), cause }) ) await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearAttachResult(attachment, target, writeId, false) } catch (error) { if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { const attachment = await this.refetchLinearAttachmentAfterDuplicate( writeId, target.issue.id, target.workspaceId, () => this.linearCreateStyleUnconfirmed('attach', writeId, target, { title, url: url.toString() }) ) await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier) return this.linearAttachResult(attachment, target, writeId, true) } throw error } } async linearIssueCreate(params: { title: string body?: string teamInput?: string teamKey?: string state?: string assignee?: string priority?: number estimate?: number dueDate?: string labels?: string[] projectInput?: string parentInput?: string parentCurrent?: boolean workspaceId?: string writeId?: string context?: LinearCurrentIssueContextHints }): Promise { if ((params.body?.length ?? 0) > LINEAR_WRITE_BODY_CAP) { throw linearError('linear_body_too_large', 'Linear issue body is too large.') } const parent = params.parentInput || params.parentCurrent ? await this.resolveLinearAgentWriteTarget({ input: params.parentInput, current: params.parentCurrent, workspaceId: params.workspaceId, context: params.context }) : null if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) { throw linearError( 'linear_invalid_workspace', 'The parent issue belongs to a different workspace.' ) } const team = await this.resolveLinearCreateTeam( params.teamInput ?? params.teamKey, params.workspaceId, parent ) const createFields = await this.resolveLinearCreateFields(params, team) const parentId = parent?.issue.id ?? null const writeId = params.writeId ?? randomUUID() const existing = params.writeId !== undefined ? await this.getMatchingLinearCreatedIssue( writeId, team.id, parentId, team.workspaceId, true, createFields ) : null if (existing) { if (parent) { await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) } return this.linearCreateResult(existing, team.workspaceId, writeId, true) } try { const issue = await this.runLinearAgentWrite( async (signal) => { const created = await createLinearIssueForAgent( team.id, params.title, params.body, team.workspaceId, { id: writeId, parentId, ...createFields, signal } ) if (!this.linearCreatedIssueMatchesIntent(created, createFields)) { throw new LinearWriteFailure( 'unconfirmed', 'Linear issue create could not be confirmed with the requested task fields.' ) } return created }, (cause) => this.linearCreateStyleUnconfirmed('create', writeId, null, { team, parent, title: params.title, bodyRequired: params.body !== undefined, createFields, cause }) ) if (parent) { await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) } return this.linearCreateResult(issue, team.workspaceId, writeId, false) } catch (error) { if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { const issue = await this.refetchLinearIssueAfterDuplicate( writeId, team.id, parentId, team.workspaceId, createFields, () => this.linearCreateStyleUnconfirmed('create', writeId, null, { team, parent, title: params.title, bodyRequired: params.body !== undefined, createFields }) ) if (parent) { await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier) } return this.linearCreateResult(issue, team.workspaceId, writeId, true) } throw error } } private async resolveLinearAgentWriteTarget(params: { input?: string current?: boolean workspaceId?: string context?: LinearCurrentIssueContextHints }): Promise { const result = await readLinearIssueContext( { input: params.input, current: params.current, workspaceId: params.workspaceId, include: { comments: false, children: false, attachments: false, relations: false, activity: false }, depth: 0, context: params.context }, (context) => this.linearResolveCurrentIssue(context) ) return { issue: result.issue, workspaceId: result.meta.resolved.workspaceId } } private async getLinearTeamStatesForWrite( teamId: string, workspaceId: string ): Promise>> { try { return await getLinearTeamStatesOrThrow(teamId, workspaceId) } catch (error) { throw this.mapLinearReadFailure(error) } } private resolveLinearAgentState( input: string, states: Awaited> ): Awaited>[number] | null { const normalized = input.toLocaleLowerCase() const exact = states.find( (state) => state.id.toLocaleLowerCase() === normalized || state.name.toLocaleLowerCase() === normalized ) // Why: Linear MCP accepts lifecycle types; keep explicit IDs/names authoritative when they collide. return exact ?? states.find((state) => state.type.toLocaleLowerCase() === normalized) ?? null } private async getLinearTeamLabelsForWrite( teamId: string, workspaceId: string ): Promise>> { try { return await getLinearTeamLabelsOrThrow(teamId, workspaceId) } catch (error) { throw this.mapLinearReadFailure(error) } } private async readLinearAgentIssueWriteRecord( issueId: string, workspaceId: string ): Promise>>> { const issue = await this.readLinearWriteLookup(() => getLinearIssueByUuidForAgent(issueId, workspaceId) ) if (!issue) { throw linearError('linear_issue_not_found', 'Linear issue was not found.') } return issue } private async buildLinearTaskUpdate( params: LinearIssueTaskUpdateRequest, current: NonNullable>>, workspaceId: string ): Promise<{ fields: { assigneeId?: string | null priority?: number estimate?: number | null dueDate?: string | null labelIds?: string[] } labels?: { id: string; name: string }[] } | null> { if (params.operation === 'assignee') { const assigneeId = params.assigneeMe ? (await this.getLinearViewerForWrite(workspaceId)).id : params.assigneeId if (assigneeId === undefined) { throw linearError('linear_invalid_assignee', 'Pass --me, --to-id, or clear assignee.') } return { fields: { assigneeId } } } if (params.operation === 'priority') { if (params.priority === undefined) { throw linearError('linear_write_failed', 'Missing priority value.') } return { fields: { priority: params.priority } } } if (params.operation === 'estimate') { if (params.estimate === undefined) { throw linearError('linear_write_failed', 'Missing estimate value.') } return { fields: { estimate: params.estimate } } } if (params.operation === 'dueDate') { if (params.dueDate === undefined) { throw linearError('linear_write_failed', 'Missing due date value.') } return { fields: { dueDate: params.dueDate } } } if (params.operation === 'labels') { const mode = params.labelMode const inputs = params.labels ?? [] if (!mode || inputs.length === 0) { throw linearError('linear_invalid_label', 'Pass at least one --label.') } const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId) const requestedIds = labels.map((label) => label.id) const existingIds = current.labelIds ?? current.labels?.map((label) => label.id) ?? [] const nextIds = mode === 'set' ? requestedIds : mode === 'add' ? Array.from(new Set([...existingIds, ...requestedIds])) : existingIds.filter((id) => !requestedIds.includes(id)) return { fields: { labelIds: nextIds }, labels: labelsForIds(nextIds, [...(current.labels ?? []), ...labels]) } } return null } private async buildLinearSaveUpdate( params: LinearSaveIssueRequest, current: NonNullable>>, workspaceId: string ): Promise { const fields: LinearIssueUpdate = {} if (params.title !== undefined) { fields.title = params.title } if (params.description !== undefined) { fields.description = params.description } if (params.priority !== undefined) { fields.priority = params.priority } if (params.estimate !== undefined) { fields.estimate = params.estimate } if (params.dueDate !== undefined) { fields.dueDate = params.dueDate } if (params.state !== undefined) { const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId) const state = this.resolveLinearAgentState(params.state, states) if (!state) { throw linearError( 'linear_invalid_state', `No workflow state exactly matched "${params.state}".` ) } fields.stateId = state.id } if (params.assignee !== undefined) { fields.assigneeId = params.assignee === null ? null : await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId) } if (params.labels !== undefined) { if (params.labels.length === 0) { fields.labelIds = [] } else { const labels = await this.resolveLinearLabelsForIssue(current, params.labels, workspaceId) fields.labelIds = labels.map((label) => label.id) } } if (params.project !== undefined) { fields.projectId = params.project === null ? null : ( await this.resolveLinearCreateProject(params.project, { id: current.team.id, workspaceId }) ).id } if (params.parentId !== undefined) { fields.parentId = params.parentId === null ? null : ( await this.resolveLinearAgentWriteTarget({ input: params.parentId, workspaceId, context: params.context }) ).issue.id if (fields.parentId === current.id) { throw linearError('linear_invalid_parent', 'An issue cannot be its own parent.') } } return fields } private async resolveLinearAssignee( input: string, teamId: string, workspaceId: string ): Promise { if (input.toLocaleLowerCase() === 'me') { return (await this.getLinearViewerForWrite(workspaceId)).id } // Why: caller-supplied IDs were accepted directly before save-issue; avoid a paginated member scan on that existing fast path. if (isLinearUuid(input)) { return input } let members: Awaited> try { members = await getLinearTeamMembersOrThrow(teamId, workspaceId) } catch (error) { throw this.mapLinearReadFailure(error) } const normalized = input.toLocaleLowerCase() const matches = members.filter( (member) => member.id.toLocaleLowerCase() === normalized || member.displayName.toLocaleLowerCase() === normalized || member.name?.toLocaleLowerCase() === normalized || member.email?.toLocaleLowerCase() === normalized ) if (matches.length === 1) { return matches[0].id } throw linearError( 'linear_invalid_assignee', matches.length === 0 ? `No team member exactly matched "${input}".` : `Multiple team members exactly matched "${input}".` ) } private linearSavedIssueMatchesIntent( issue: NonNullable>>, fields: LinearIssueUpdate ): boolean { if (fields.title !== undefined && issue.title !== fields.title) { return false } if (fields.description !== undefined && (issue.description ?? '') !== fields.description) { return false } if (fields.parentId !== undefined && (issue.parent?.id ?? null) !== fields.parentId) { return false } if (fields.stateId !== undefined && issue.state?.id !== fields.stateId) { return false } if (fields.assigneeId !== undefined && (issue.assignee?.id ?? null) !== fields.assigneeId) { return false } if (fields.priority !== undefined && issue.priority !== fields.priority) { return false } if (fields.estimate !== undefined && (issue.estimate ?? null) !== fields.estimate) { return false } if (fields.dueDate !== undefined && (issue.dueDate ?? null) !== fields.dueDate) { return false } if (fields.projectId !== undefined && (issue.project?.id ?? null) !== fields.projectId) { return false } const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? [] return fields.labelIds === undefined || sameStringSet(issueLabelIds, fields.labelIds) } private async resolveLinearCreateFields( params: { state?: string assignee?: string priority?: number estimate?: number dueDate?: string labels?: string[] projectInput?: string }, team: { id: string; workspaceId: string } ): Promise { const fields: LinearCreateFieldIntent = {} if (params.state) { const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId) const state = this.resolveLinearAgentState(params.state, states) if (!state) { throw linearError( 'linear_invalid_state', `No workflow state exactly matched "${params.state}".`, { states: states.map(({ id, name, type }) => ({ id, name, type })) } ) } fields.stateId = state.id } if (params.assignee) { fields.assigneeId = await this.resolveLinearAssignee( params.assignee, team.id, team.workspaceId ) } if (params.priority !== undefined) { fields.priority = params.priority } if (params.estimate !== undefined) { fields.estimate = params.estimate } if (params.dueDate !== undefined) { fields.dueDate = params.dueDate } if (params.labels && params.labels.length > 0) { const labels = await this.resolveLinearLabelsForTeam(team.id, params.labels, team.workspaceId) fields.labelIds = labels.map((label) => label.id) } if (params.projectInput) { const project = await this.resolveLinearCreateProject(params.projectInput, team) fields.projectId = project.id } return fields } private async resolveLinearCreateProject( input: string, team: { id: string; workspaceId: string } ): Promise { const trimmed = input.trim() if (!trimmed) { throw linearError('linear_invalid_project', 'Pass a non-empty Linear project id or name.') } const byId = isLinearUuid(trimmed) ? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId) : null if (byId) { await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed) return byId } const searchCandidates = await this.readLinearProjectsForCreate(trimmed, team.workspaceId) const normalized = trimmed.toLowerCase() const idMatch = searchCandidates.find((project) => project.id.toLowerCase() === normalized) if (idMatch) { await this.assertLinearProjectIncludesTeam(idMatch, team.id, team.workspaceId, trimmed) return idMatch } const slugMatch = searchCandidates.find( (project) => project.slugId?.toLowerCase() === normalized ) if (slugMatch) { await this.assertLinearProjectIncludesTeam(slugMatch, team.id, team.workspaceId, trimmed) return slugMatch } const nameMatches = await this.readLinearProjectsByExactNameForCreate(trimmed, team.workspaceId) const compatibleNameMatches = await this.filterLinearProjectsForTeam( nameMatches, team.id, team.workspaceId ) if (compatibleNameMatches.length === 1) { return compatibleNameMatches[0] } if (compatibleNameMatches.length > 1) { throw linearError( 'linear_invalid_project', `Multiple Linear projects exactly matched "${trimmed}".`, { projects: compatibleNameMatches.map((project) => ({ id: project.id, name: project.name, teams: project.teams })), nextSteps: ['Run `orca linear project list --query --json` and retry by id.'] } ) } if (nameMatches.length > 0) { await this.assertLinearProjectIncludesTeam(nameMatches[0], team.id, team.workspaceId, trimmed) } throw linearError('linear_invalid_project', `No Linear project exactly matched "${trimmed}".`, { projects: searchCandidates.map((project) => ({ id: project.id, name: project.name, teams: project.teams })), nextSteps: ['Run `orca linear project list --query --json` and retry by id.'] }) } private async readLinearProjectByIdForCreate( id: string, workspaceId: string ): Promise { try { return await getLinearProject(id, workspaceId, true) } catch (error) { throw this.mapLinearReadFailure(error) } } private async readLinearProjectsForCreate( query: string, workspaceId: string ): Promise { try { return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true)).items } catch (error) { throw this.mapLinearReadFailure(error) } } private async readLinearProjectsByExactNameForCreate( name: string, workspaceId: string ): Promise { try { return await listLinearProjectsByExactName(name, workspaceId, true) } catch (error) { throw this.mapLinearReadFailure(error) } } private async assertLinearProjectIncludesTeam( project: LinearProjectSummary, teamId: string, workspaceId: string, input: string ): Promise { if (this.linearProjectIncludesTeam(project, teamId)) { return } let teams: NonNullable = [] try { // Why: summary reads cap project teams, so large cross-team projects need a paged membership check before rejecting a valid create. teams = await listLinearProjectTeams(project.id, workspaceId, true) } catch (error) { throw this.mapLinearReadFailure(error) } if (teams.some((team) => team.id === teamId)) { return } throw linearError( 'linear_invalid_project', `Linear project "${input}" is not available to the target team.`, { project: { id: project.id, name: project.name, teams }, nextSteps: ['Choose a project that includes the create target team, then retry by id.'] } ) } private async filterLinearProjectsForTeam( projects: LinearProjectSummary[], teamId: string, workspaceId: string ): Promise { const compatible: LinearProjectSummary[] = [] for (const project of projects) { if (this.linearProjectIncludesTeam(project, teamId)) { compatible.push(project) continue } try { const teams = await listLinearProjectTeams(project.id, workspaceId, true) if (teams.some((team) => team.id === teamId)) { compatible.push({ ...project, teams }) } } catch (error) { throw this.mapLinearReadFailure(error) } } return compatible } private linearProjectIncludesTeam(project: LinearProjectSummary, teamId: string): boolean { return project.teams?.some((team) => team.id === teamId) === true } private async getLinearViewerForWrite( workspaceId: string ): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> { try { return await getLinearViewerForWorkspaceOrThrow(workspaceId) } catch (error) { throw this.mapLinearReadFailure(error) } } private async resolveLinearLabelsForIssue( issue: NonNullable>>, inputs: string[], workspaceId: string ): Promise<{ id: string; name: string }[]> { const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId) const resolved = inputs.map((input) => { const normalized = input.toLocaleLowerCase() const idMatch = labels.find((label) => label.id.toLocaleLowerCase() === normalized) if (idMatch) { return { id: idMatch.id, name: idMatch.name } } const nameMatches = labels.filter((label) => label.name.toLocaleLowerCase() === normalized) if (nameMatches.length === 1) { return { id: nameMatches[0].id, name: nameMatches[0].name } } throw linearError( 'linear_invalid_label', nameMatches.length === 0 ? `No label exactly matched "${input}".` : `Multiple labels exactly matched "${input}".`, { labels: labels.map((label) => ({ id: label.id, name: label.name })), nextSteps: ['Run `orca linear team labels --team --json` and retry by id.'] } ) }) return Array.from(new Map(resolved.map((label) => [label.id, label])).values()) } private async resolveLinearLabelsForTeam( teamId: string, inputs: string[], workspaceId: string ): Promise<{ id: string; name: string }[]> { const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId) const resolved = inputs.map((input) => { const normalized = input.toLocaleLowerCase() const idMatch = labels.find((label) => label.id.toLocaleLowerCase() === normalized) if (idMatch) { return { id: idMatch.id, name: idMatch.name } } const nameMatches = labels.filter((label) => label.name.toLocaleLowerCase() === normalized) if (nameMatches.length === 1) { return { id: nameMatches[0].id, name: nameMatches[0].name } } throw linearError( 'linear_invalid_label', nameMatches.length === 0 ? `No label exactly matched "${input}".` : `Multiple labels exactly matched "${input}".`, { labels: labels.map((label) => ({ id: label.id, name: label.name })) } ) }) return Array.from(new Map(resolved.map((label) => [label.id, label])).values()) } private linearCreatedIssueMatchesIntent( issue: NonNullable>>, intent: LinearCreateFieldIntent ): boolean { if (intent.stateId !== undefined && issue.state?.id !== intent.stateId) { return false } if (intent.assigneeId !== undefined && (issue.assignee?.id ?? null) !== intent.assigneeId) { return false } if (intent.priority !== undefined && issue.priority !== intent.priority) { return false } if (intent.estimate !== undefined && (issue.estimate ?? null) !== intent.estimate) { return false } if (intent.dueDate !== undefined && (issue.dueDate ?? null) !== intent.dueDate) { return false } if (intent.projectId !== undefined && (issue.project?.id ?? null) !== intent.projectId) { return false } const issueLabelIds = issue.labelIds ?? issue.labels?.map((label) => label.id) ?? [] if (intent.labelIds !== undefined && !sameStringSet(issueLabelIds, intent.labelIds)) { return false } return true } private linearTaskFieldAlreadySet( operation: LinearIssueTaskUpdateRequest['operation'], record: NonNullable>>, update: { fields: { assigneeId?: string | null priority?: number estimate?: number | null dueDate?: string | null labelIds?: string[] } } ): boolean { if (operation === 'assignee') { return (record.assignee?.id ?? null) === update.fields.assigneeId } if (operation === 'priority') { return record.priority === update.fields.priority } if (operation === 'estimate') { return (record.estimate ?? null) === update.fields.estimate } if (operation === 'dueDate') { return (record.dueDate ?? null) === update.fields.dueDate } if (operation === 'labels') { const recordLabelIds = record.labelIds ?? record.labels?.map((label) => label.id) ?? [] return sameStringSet(recordLabelIds, update.fields.labelIds ?? []) } return false } private linearTaskUpdateResult( operation: LinearIssueTaskUpdateRequest['operation'], issue: LinearIssueSummary, workspaceId: string, previous: NonNullable>>, current: NonNullable>>, alreadySet: boolean ): LinearIssueTaskUpdateResult { return { issue: this.linearWriteIssueRef(issue), operation, previous: this.linearTaskResultFields(previous), current: this.linearTaskResultFields(current), meta: { workspaceId, alreadySet } } } private linearTaskResultFields( record: NonNullable>> ): LinearIssueTaskUpdateResult['current'] { return { assignee: record.assignee ?? null, priority: record.priority ?? null, estimate: record.estimate ?? null, dueDate: record.dueDate ?? null, labels: record.labels ?? [] } } private async resolveLinearCommentParentId( issueId: string, commentId: string, workspaceId: string ): Promise { try { const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId) if (!root) { throw linearError( 'linear_invalid_parent', 'The reply target is not a comment on this issue.', { nextSteps: ['Run `orca linear issue --comments --json` to list valid comment ids.'] } ) } return root.id } catch (error) { if (error instanceof LinearAgentAccessError) { throw error } throw this.mapLinearReadFailure(error) } } private async runLinearAgentWrite( write: (signal: AbortSignal) => Promise, unconfirmed: (cause?: string) => LinearAgentAccessError ): Promise { const controller = new AbortController() const writePromise = write(controller.signal) writePromise.catch(() => undefined) let timer: ReturnType | null = null try { return await Promise.race([ writePromise, new Promise((_resolve, reject) => { timer = setTimeout(() => { controller.abort() reject( new LinearWriteFailure( 'unconfirmed', 'Linear write deadline elapsed before confirmation.' ) ) }, 25_000) }) ]) } catch (error) { if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') { throw error } if (error instanceof LinearWriteFailure && error.kind === 'unconfirmed') { throw unconfirmed(this.linearWriteFailureCauseMessage(error)) } if (error instanceof LinearWriteFailure && error.kind === 'network') { throw linearError('linear_network_error', sanitizeLinearErrorMessage(error.message)) } if (error instanceof LinearWriteFailure) { throw linearError('linear_write_failed', sanitizeLinearErrorMessage(error.message)) } throw this.mapLinearReadFailure(error) } finally { if (timer) { clearTimeout(timer) } } } private linearWriteFailureCauseMessage(error: LinearWriteFailure): string { if (error.cause instanceof Error) { return sanitizeLinearErrorMessage(error.cause.message) } if (error.cause !== undefined) { return sanitizeLinearErrorMessage(String(error.cause)) } return sanitizeLinearErrorMessage(error.message) } private mapLinearReadFailure(error: unknown): LinearAgentAccessError { if (error instanceof LinearAgentAccessError) { return error } if (isLinearAuthError(error)) { return linearError('linear_auth_expired', 'Linear authentication expired.', { nextSteps: ['Reconnect Linear from Orca settings.'] }) } return linearError(classifyLinearError(error), linearMessage(error)) } private async getMatchingLinearCommentWrite( writeId: string, issueId: string, parentId: string | null, workspaceId: string, required: boolean ): Promise> | null> { const comment = await this.readLinearWriteLookup(() => getLinearCommentByUuidForAgent(writeId, workspaceId) ) if (!comment) { return null } if (comment.issue.id === issueId && comment.parentId === parentId) { return comment } if (required) { throw linearError( 'linear_invalid_write_id', 'The write id belongs to a different comment target.' ) } return null } private async getMatchingLinearAttachmentWrite( writeId: string, issueId: string, workspaceId: string, required: boolean ): Promise> | null> { const attachment = await this.readLinearWriteLookup(() => getLinearAttachmentByUuidForAgent(writeId, workspaceId) ) if (!attachment) { return null } if (attachment.issue.id === issueId) { return attachment } if (required) { throw linearError( 'linear_invalid_write_id', 'The write id belongs to a different attachment target.' ) } return null } private async getMatchingLinearCreatedIssue( writeId: string, teamId: string, parentId: string | null, workspaceId: string, required: boolean, intent: LinearCreateFieldIntent = {} ): Promise> | null> { const issue = await this.readLinearWriteLookup(() => getLinearIssueByUuidForAgent(writeId, workspaceId) ) if (!issue) { return null } if ( issue.team.id === teamId && (issue.parent?.id ?? null) === parentId && this.linearCreatedIssueMatchesIntent(issue, intent) ) { return issue } if (required) { throw linearError( 'linear_invalid_write_id', 'The write id belongs to a different issue target.' ) } return null } private async refetchLinearCommentAfterDuplicate( writeId: string, issueId: string, parentId: string | null, workspaceId: string, unconfirmed: (cause?: string) => LinearAgentAccessError ): Promise>>> { try { // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. const comment = await this.getMatchingLinearCommentWrite( writeId, issueId, parentId, workspaceId, true ) if (comment) { return comment } } catch (error) { if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { throw error } throw unconfirmed( error instanceof Error ? sanitizeLinearErrorMessage(error.message) : sanitizeLinearErrorMessage(String(error)) ) } throw unconfirmed() } private async refetchLinearAttachmentAfterDuplicate( writeId: string, issueId: string, workspaceId: string, unconfirmed: (cause?: string) => LinearAgentAccessError ): Promise>>> { try { // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. const attachment = await this.getMatchingLinearAttachmentWrite( writeId, issueId, workspaceId, true ) if (attachment) { return attachment } } catch (error) { if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { throw error } throw unconfirmed( error instanceof Error ? sanitizeLinearErrorMessage(error.message) : sanitizeLinearErrorMessage(String(error)) ) } throw unconfirmed() } private async refetchLinearIssueAfterDuplicate( writeId: string, teamId: string, parentId: string | null, workspaceId: string, intent: LinearCreateFieldIntent, unconfirmed: (cause?: string) => LinearAgentAccessError ): Promise>>> { try { // Why: a duplicate-id response can mean the original write landed; only the exact target relationship proves this pinned retry. const issue = await this.getMatchingLinearCreatedIssue( writeId, teamId, parentId, workspaceId, true, intent ) if (issue) { return issue } } catch (error) { if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_write_id') { throw error } throw unconfirmed( error instanceof Error ? sanitizeLinearErrorMessage(error.message) : sanitizeLinearErrorMessage(String(error)) ) } throw unconfirmed() } private async readLinearWriteLookup(lookup: () => Promise): Promise { try { return await lookup() } catch (error) { throw this.mapLinearReadFailure(error) } } private parseLinearAttachmentUrl(value: string): URL { try { const url = new URL(value) if (url.protocol === 'http:' || url.protocol === 'https:') { return url } } catch { // Fall through to the stable agent-facing error below. } throw linearError('linear_invalid_url', 'Attachment URL must be an absolute http(s) URL.') } private defaultLinearAttachmentTitle(url: URL): string { const tail = url.pathname.split('/').findLast(Boolean) return tail ? `${url.host}/${tail}` : url.host } private linearWorkspaceErrorCode(type: string): LinearErrorCode { if (type === 'auth') { return 'linear_auth_expired' } if (type === 'network') { return 'linear_network_error' } if (type === 'rate_limited') { return 'linear_rate_limited' } return 'linear_write_failed' } private linearTeamSummary(team: { id: string name: string key: string url?: string workspaceId?: string workspaceName?: string }): { id: string name: string key: string url?: string workspace?: { id: string; name: string } } { return { id: team.id, name: team.name, key: team.key, ...(team.url ? { url: team.url } : {}), ...(team.workspaceId ? { workspace: { id: team.workspaceId, name: team.workspaceName ?? team.workspaceId } } : {}) } } private async resolveLinearTeamInput( teamInput: string, workspaceId?: string | 'all' ): Promise<{ id: string key: string name: string workspaceId: string workspaceName?: string }> { this.validateLinearCreateWorkspaceScope(workspaceId === 'all' ? undefined : workspaceId) let teams: Awaited> try { teams = await listLinearTeamsOrThrow(workspaceId ?? 'all') } catch (error) { throw this.mapLinearReadFailure(error) } const normalized = teamInput.toLocaleLowerCase() const idMatches = teams.filter((team) => team.id.toLocaleLowerCase() === normalized) const matches = idMatches.length > 0 ? idMatches : teams.filter((team) => team.key.toLocaleLowerCase() === normalized) if (matches.length === 1 && matches[0].workspaceId) { return { id: matches[0].id, key: matches[0].key, name: matches[0].name, workspaceId: matches[0].workspaceId, workspaceName: matches[0].workspaceName } } if (matches.length > 1) { throw linearError( 'linear_workspace_ambiguous', `Team ${teamInput} exists in multiple workspaces.`, { candidates: matches.map((team) => ({ workspaceId: team.workspaceId, workspaceName: team.workspaceName, teamId: team.id, teamKey: team.key })) } ) } throw linearError('linear_team_required', `No connected Linear team matched ${teamInput}.`) } private async resolveLinearCreateTeam( teamInput: string | undefined, workspaceId: string | undefined, parent: LinearAgentWriteTarget | null ): Promise<{ id: string; key: string; name: string; workspaceId: string }> { if (!teamInput && parent?.issue.team?.id && parent.issue.team.key && parent.issue.team.name) { return { id: parent.issue.team.id, key: parent.issue.team.key, name: parent.issue.team.name, workspaceId: parent.workspaceId } } if (!teamInput) { throw linearError('linear_team_required', 'Pass --team or create under a parent issue.', { nextSteps: ['Run `orca linear create --team ...` or use --parent-current.'] }) } const scope = parent?.workspaceId ?? workspaceId this.validateLinearCreateWorkspaceScope(scope) let teams: Awaited> try { teams = await listLinearTeamsOrThrow(scope ?? 'all') } catch (error) { throw this.mapLinearReadFailure(error) } if (teams.length === 0 && (getLinearStatus().workspaces?.length ?? 0) === 0) { throw linearError('linear_not_connected', 'Linear is not connected.', { nextSteps: ['Connect Linear from Orca settings, then retry the issue create.'] }) } const matches = teams.filter( (team) => team.id.toLocaleLowerCase() === teamInput.toLocaleLowerCase() || team.key.toLocaleLowerCase() === teamInput.toLocaleLowerCase() ) if (matches.length === 1 && matches[0].workspaceId) { return { id: matches[0].id, key: matches[0].key, name: matches[0].name, workspaceId: matches[0].workspaceId } } if (matches.length > 1) { throw linearError( 'linear_workspace_ambiguous', `Team ${teamInput} exists in multiple workspaces.`, { candidates: matches.map((team) => ({ workspaceId: team.workspaceId, workspaceName: team.workspaceName, teamKey: team.key })) } ) } if (parent) { let globalTeams: Awaited> try { globalTeams = await listLinearTeamsOrThrow('all') } catch (error) { throw this.mapLinearReadFailure(error) } const globalMatch = globalTeams.find( (team) => team.id.toLocaleLowerCase() === teamInput.toLocaleLowerCase() || team.key.toLocaleLowerCase() === teamInput.toLocaleLowerCase() ) if (globalMatch) { throw linearError( 'linear_invalid_workspace', `Team ${teamInput} is not in the parent issue workspace.` ) } } throw linearError('linear_team_required', `No connected Linear team matched ${teamInput}.`) } private validateLinearCreateWorkspaceScope(workspaceId: string | undefined): void { if (!workspaceId) { return } const workspaces = getLinearStatus().workspaces ?? [] if (workspaces.length > 0 && !workspaces.some((workspace) => workspace.id === workspaceId)) { throw linearError( 'linear_invalid_workspace', `No connected Linear workspace matched ${workspaceId}.` ) } } private linearWriteIssueRef(issue: { id: string; identifier: string; url: string }): { id: string identifier: string url: string } { return { id: issue.id, identifier: issue.identifier, url: issue.url } } private linearCommentResult( comment: NonNullable>>, target: LinearAgentWriteTarget, bodyChars: number, writeId: string, deduplicated: boolean ): LinearCommentAddResult { return { comment: { id: comment.id, url: comment.url, parentId: comment.parentId }, issue: this.linearWriteIssueRef(target.issue), meta: { workspaceId: target.workspaceId, bodyChars, writeId, deduplicated } } } private linearAttachResult( attachment: NonNullable>>, target: LinearAgentWriteTarget, writeId: string, deduplicated: boolean ): LinearAttachResult { return { attachment: { id: attachment.id, title: attachment.title, url: attachment.url }, issue: this.linearWriteIssueRef(target.issue), meta: { workspaceId: target.workspaceId, writeId, deduplicated } } } private linearCreateResult( issue: NonNullable>>, workspaceId: string, writeId: string, deduplicated: boolean ): LinearCreateResult { return { issue, meta: { workspaceId, writeId, deduplicated } } } private linearCreateFieldRetryTokens(fields: LinearCreateFieldIntent | undefined): string[] { if (!fields) { return [] } return [ ...(fields.stateId ? [`--state=${this.commandToken(fields.stateId, 'STATE_ID')}`] : []), ...(fields.assigneeId ? [`--assignee=${this.commandToken(fields.assigneeId, 'ASSIGNEE_ID')}`] : []), ...(fields.priority !== undefined ? [`--priority=${this.linearPriorityRetryToken(fields.priority)}`] : []), ...(fields.estimate !== undefined && fields.estimate !== null ? [`--estimate=${fields.estimate}`] : []), ...(fields.dueDate ? [`--due-date=${fields.dueDate}`] : []), ...(fields.projectId ? [`--project=${this.commandToken(fields.projectId, 'PROJECT_ID')}`] : []), ...(fields.labelIds ?? []).map( (labelId) => `--label=${this.commandToken(labelId, 'LABEL_ID')}` ) ] } private linearPriorityRetryToken(priority: number): string { if (priority === 1) { return 'urgent' } if (priority === 2) { return 'high' } if (priority === 3) { return 'medium' } if (priority === 4) { return 'low' } return 'none' } private linearCreateStyleUnconfirmed( verb: 'comment' | 'attach' | 'create', writeId: string, target: LinearAgentWriteTarget | null, extra: { parentId?: string | null team?: { id: string; key: string; name: string; workspaceId: string } parent?: LinearAgentWriteTarget | null title?: string url?: string bodyRequired?: boolean createFields?: LinearCreateFieldIntent cause?: string } = {} ): LinearAgentAccessError { const workspaceId = target?.workspaceId ?? extra.team?.workspaceId ?? '' // Why: the retry preserves id and target so duplicate recovery can prove intent without matching mutable content. const pinned = verb === 'create' ? [ 'orca linear create', `--workspace=${this.commandToken(workspaceId, 'WORKSPACE_ID')}`, `--write-id=${this.commandToken(writeId, 'WRITE_ID')}`, '--title TITLE_HERE', ...(extra.bodyRequired ? ['--body-file -'] : []), ...(extra.parent ? [`--parent=${this.commandToken(extra.parent.issue.identifier, 'PARENT_ISSUE')}`] : []), ...(extra.team ? [`--team=${this.commandToken(extra.team.key, 'TEAM_KEY')}`] : [] ).concat(this.linearCreateFieldRetryTokens(extra.createFields)) ].join(' ') : [ `orca linear ${verb === 'attach' ? 'attach' : 'comment add'}`, this.commandToken(target?.issue.identifier ?? '', 'ISSUE_ID'), `--workspace=${this.commandToken(workspaceId, 'WORKSPACE_ID')}`, `--write-id=${this.commandToken(writeId, 'WRITE_ID')}`, ...(verb === 'comment' ? ['--body-file -'] : []), ...(verb === 'comment' && extra.parentId ? [`--reply-to=${this.commandToken(extra.parentId, 'COMMENT_ID')}`] : []), ...(verb === 'attach' ? ['--url URL_HERE', '--title TITLE_HERE'] : []) ].join(' ') const retryPrefix = extra.bodyRequired || verb === 'comment' ? 'Pipe the same body and r' : 'R' const payloadNote = verb === 'attach' ? ' Replace TITLE_HERE/URL_HERE with the exact original payload values before running.' : verb === 'create' ? ' Replace TITLE_HERE with the exact original title before running.' : '' return linearError( 'linear_write_unconfirmed', 'Linear may have applied the write, but Orca could not confirm it.', { writeId, workspaceId, issueIdentifier: target?.issue.identifier, parentId: extra.parentId, team: extra.team ? { id: extra.team.id, key: extra.team.key } : undefined, parentIdentifier: extra.parent?.issue.identifier, createFields: extra.createFields, nextSteps: [ `${retryPrefix}etry once with the pinned command: \`${pinned}\`.${payloadNote}` ], ...(extra.cause ? { cause: sanitizeLinearErrorMessage(extra.cause) } : {}) } ) } private commandToken(value: string, placeholder: string): string { return /^[A-Za-z0-9._:@%+=,/-]+$/.test(value) ? value : placeholder } private async notifyLinearLinkedIssueUpdated( workspaceId: string, identifier: string | readonly string[] ): Promise { const identifiers = typeof identifier === 'string' ? [identifier] : identifier const normalized = new Map( identifiers.map((value) => [value.toLocaleUpperCase(), value] as const) ) for (const worktree of await this.listResolvedWorktrees()) { const linkedIdentifier = normalized.get( (worktree.linkedLinearIssue ?? '').toLocaleUpperCase() ) if (!linkedIdentifier) { continue } const linkedWorkspaceId = worktree.linkedLinearIssueWorkspaceId ?? workspaceId if (linkedWorkspaceId !== workspaceId) { continue } this.emitClientEvent({ type: 'linearLinkedIssueUpdated', worktreeId: worktree.id, identifier: linkedIdentifier, workspaceId }) } } linearIssueComments( issueId: string, workspaceId?: string ): ReturnType { return getLinearIssueComments(issueId, workspaceId) } linearListTeams(workspaceId?: LinearWorkspaceSelection): ReturnType { return listLinearTeams(workspaceId) } linearListProjects( query?: string, limit = 20, workspaceId?: LinearWorkspaceSelection, force?: boolean ): ReturnType { return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force) } linearCreateProject( input: LinearProjectCreateInput, workspaceId?: string ): ReturnType { return createLinearProject(input, workspaceId) } linearGetProject( id: string, workspaceId: string, force?: boolean ): ReturnType { return getLinearProject(id, workspaceId, force) } linearListProjectIssues( projectId: string, limit = 20, workspaceId: string, force?: boolean ): ReturnType { return listLinearProjectIssues(projectId, clampLinearIssueListLimit(limit), workspaceId, force) } linearListCustomViews( model: LinearCustomViewModel, limit = 20, workspaceId?: LinearWorkspaceSelection, force?: boolean ): ReturnType { return listLinearCustomViews(model, Math.min(Math.max(1, limit), 50), workspaceId, force) } linearGetCustomView( viewId: string, model: LinearCustomViewModel, workspaceId: string, force?: boolean ): ReturnType { return getLinearCustomView(viewId, model, workspaceId, force) } linearListCustomViewIssues( viewId: string, limit = 20, workspaceId: string, force?: boolean ): ReturnType { return listLinearCustomViewIssues(viewId, clampLinearIssueListLimit(limit), workspaceId, force) } linearListCustomViewProjects( viewId: string, limit = 20, workspaceId: string, force?: boolean ): ReturnType { return listLinearCustomViewProjects( viewId, Math.min(Math.max(1, limit), 50), workspaceId, force ) } linearTeamStates(teamId: string, workspaceId?: string): ReturnType { return getLinearTeamStates(teamId, workspaceId) } linearTeamLabels(teamId: string, workspaceId?: string): ReturnType { return getLinearTeamLabels(teamId, workspaceId) } linearTeamMembers(teamId: string, workspaceId?: string): ReturnType { return getLinearTeamMembers(teamId, workspaceId) } // ── Jira integration ── jiraConnect(args: JiraConnectArgs): ReturnType { return connectJira(args) } jiraDisconnect(siteId?: string): { ok: true } { disconnectJira(siteId) return { ok: true } } jiraSelectSite(siteId: JiraSiteSelection): ReturnType { return selectJiraSite(siteId) } jiraStatus(): ReturnType { return getJiraStatus() } jiraReadStatus(): ReturnType { return getJiraStatus() } jiraTestConnection(siteId?: string): ReturnType { return testJiraConnection(siteId) } jiraSearchIssues( jql: string, limit = 30, siteId?: JiraSiteSelection, signal?: AbortSignal ): ReturnType { return searchJiraIssues(jql, Math.min(Math.max(1, limit), 100), siteId, signal) } jiraListIssues( filter?: JiraIssueFilter, limit = 30, siteId?: JiraSiteSelection ): ReturnType { return listJiraIssues(filter, Math.min(Math.max(1, limit), 100), siteId) } jiraCreateIssue(args: JiraCreateIssueArgs): ReturnType { return createJiraIssue(args) } jiraGetIssue(key: string, siteId?: string): ReturnType { return getJiraIssue(key, siteId) } jiraLookupIssueSummary( key: string, siteId: string, signal?: AbortSignal ): ReturnType { return getJiraIssueSummary(key, siteId, signal) } jiraUpdateIssue( key: string, updates: JiraIssueUpdate, siteId?: string ): ReturnType { return updateJiraIssue(key, updates, siteId) } jiraAddIssueComment( key: string, body: string, siteId?: string ): ReturnType { return addJiraIssueComment(key, body, siteId) } jiraIssueComments(key: string, siteId?: string): ReturnType { return getJiraIssueComments(key, siteId) } jiraListProjects(siteId?: JiraSiteSelection): ReturnType { return listJiraProjects(siteId) } jiraListIssueTypes( projectIdOrKey: string, siteId?: string ): ReturnType { return listJiraIssueTypes(projectIdOrKey, siteId) } jiraListCreateFields( projectIdOrKey: string, issueTypeId: string, siteId?: string ): ReturnType { return listJiraCreateFields(projectIdOrKey, issueTypeId, siteId) } jiraListPriorities(siteId?: string): ReturnType { return listJiraPriorities(siteId) } jiraListAssignableUsers( key: string, query?: string, siteId?: string ): ReturnType { return listJiraAssignableUsers(key, query, siteId) } jiraListTransitions(key: string, siteId?: string): ReturnType { return listJiraTransitions(key, siteId) } jiraGetProjectStatusOrder( projectKey: string, siteId?: string ): ReturnType { return getJiraProjectStatusOrder(projectKey, siteId) } // ── Browser automation ── private readonly browserCommands = new RuntimeBrowserCommands({ getAgentBrowserBridge: () => this.agentBrowserBridge, resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow(), getOffscreenBrowserBackend: () => this.offscreenBrowserBackend, // Why: bind directly, not a wrapper arrow — a hand-listed wrapper dropped targetGroupId, so a right-split browser landed in the left. markHeadlessBrowserSessionTabActive: this.markHeadlessBrowserSessionTabActive.bind(this) }) private readonly emulatorCommands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => this.emulatorBridge, resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), getSettings: () => this.requireStore().getSettings() }) browserSnapshot: RuntimeBrowserCommands['browserSnapshot'] = this.browserCommands.browserSnapshot.bind(this.browserCommands) browserClick: RuntimeBrowserCommands['browserClick'] = this.browserCommands.browserClick.bind( this.browserCommands ) browserGoto: RuntimeBrowserCommands['browserGoto'] = this.browserCommands.browserGoto.bind( this.browserCommands ) browserFill: RuntimeBrowserCommands['browserFill'] = this.browserCommands.browserFill.bind( this.browserCommands ) browserType: RuntimeBrowserCommands['browserType'] = this.browserCommands.browserType.bind( this.browserCommands ) browserSelect: RuntimeBrowserCommands['browserSelect'] = this.browserCommands.browserSelect.bind( this.browserCommands ) browserScroll: RuntimeBrowserCommands['browserScroll'] = this.browserCommands.browserScroll.bind( this.browserCommands ) browserBack: RuntimeBrowserCommands['browserBack'] = this.browserCommands.browserBack.bind( this.browserCommands ) browserReload: RuntimeBrowserCommands['browserReload'] = this.browserCommands.browserReload.bind( this.browserCommands ) browserScreenshot: RuntimeBrowserCommands['browserScreenshot'] = this.browserCommands.browserScreenshot.bind(this.browserCommands) async browserScreencast( params: Parameters[0], options: { connectionId?: string sendBinary?: (bytes: Uint8Array) => boolean | void signal?: AbortSignal emit: (result: BrowserScreencastResult) => void } ): Promise { if (!options.sendBinary) { throw new BrowserError( 'browser_error', 'Browser screencast requires a binary streaming transport.' ) } const connectionKey = options.connectionId ?? 'local' const requestedPageId = typeof params.page === 'string' ? params.page : null let existingPageStream = requestedPageId ? this.activeBrowserScreencastsByPage.get(requestedPageId) : undefined while (existingPageStream) { // Why: CDP allows only one screencast per page; cancel a stale stream so the next tab activation isn't stuck on an already-active error or old viewport. existingPageStream.cancel(existingPageStream.connectionKey !== connectionKey) await existingPageStream.done existingPageStream = requestedPageId ? this.activeBrowserScreencastsByPage.get(requestedPageId) : undefined } let existingStream = this.activeBrowserScreencastsByConnection.get(connectionKey) while (existingStream) { existingStream.cancel() await existingStream.done existingStream = this.activeBrowserScreencastsByConnection.get(connectionKey) } if (options.signal?.aborted) { throw new BrowserError('browser_error', 'Browser screencast was cancelled.') } let screencast: Awaited> | null = null let registeredSubscriptionId: string | null = null let activeBrowserPageId: string | null = null let ended = false let cancelledBeforeStart = false let readyEmitted = false let resolveActiveDone!: () => void const activeDone = new Promise((resolve) => { resolveActiveDone = resolve }) const end = (emitEnd: boolean): void => { if (ended) { return } ended = true screencast?.session.stop() if (emitEnd && screencast) { options.emit({ type: 'end', subscriptionId: screencast.subscriptionId }) } } const cancel = (emitEnd = false): void => { if (!screencast) { cancelledBeforeStart = true return } end(emitEnd) } const abortScreencast = (): void => cancel() const sendBinaryAfterReady = (bytes: Uint8Array): boolean | void => { if (!readyEmitted) { // Why: clients learn the owning subscription from ready, so CDP frames must stay unacked until the JSON ready event is delivered. return false } return options.sendBinary?.(bytes) } // Why: a phone can rotate before the first stream reaches ready (no subscriptionId yet), so a same-socket replacement cancels and waits here instead of racing. this.activeBrowserScreencastsByConnection.set(connectionKey, { cancel, done: activeDone, connectionKey }) options.signal?.addEventListener('abort', abortScreencast, { once: true }) try { screencast = await this.browserCommands.browserScreencast(params, { sendBinary: sendBinaryAfterReady, emit: options.emit }) if (cancelledBeforeStart || options.signal?.aborted) { end(false) await screencast.session.done return } activeBrowserPageId = screencast.ready.browserPageId this.activeBrowserScreencastsByPage.set(activeBrowserPageId, { cancel, done: activeDone, connectionKey }) this.setBrowserDriver(activeBrowserPageId, { kind: 'mobile', clientId: connectionKey }) // Why: screencast frames are connection-scoped; tie Page.stopScreencast to the exact socket so dropped connections don't leave Chromium streaming. this.registerSubscriptionCleanup( screencast.subscriptionId, () => end(true), options.connectionId ) registeredSubscriptionId = screencast.subscriptionId options.emit(screencast.ready) readyEmitted = true await screencast.session.done end(true) this.cleanupSubscription(screencast.subscriptionId) } finally { options.signal?.removeEventListener('abort', abortScreencast) if (!ended) { end(false) } if (registeredSubscriptionId) { this.cleanupSubscription(registeredSubscriptionId) } const active = this.activeBrowserScreencastsByConnection.get(connectionKey) if (active?.done === activeDone) { this.activeBrowserScreencastsByConnection.delete(connectionKey) } if (activeBrowserPageId) { const activePageStream = this.activeBrowserScreencastsByPage.get(activeBrowserPageId) if (activePageStream?.done === activeDone) { this.activeBrowserScreencastsByPage.delete(activeBrowserPageId) } const driver = this.getBrowserDriver(activeBrowserPageId) if (driver.kind === 'mobile' && driver.clientId === connectionKey) { this.setBrowserDriver(activeBrowserPageId, { kind: 'idle' }) } } resolveActiveDone() } } browserEval: RuntimeBrowserCommands['browserEval'] = this.browserCommands.browserEval.bind( this.browserCommands ) browserTabList: RuntimeBrowserCommands['browserTabList'] = this.browserCommands.browserTabList.bind(this.browserCommands) browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = this.browserCommands.browserProceedCertificate.bind(this.browserCommands) browserTabShow: RuntimeBrowserCommands['browserTabShow'] = this.browserCommands.browserTabShow.bind(this.browserCommands) browserTabCurrent: RuntimeBrowserCommands['browserTabCurrent'] = this.browserCommands.browserTabCurrent.bind(this.browserCommands) browserTabSwitch: RuntimeBrowserCommands['browserTabSwitch'] = this.browserCommands.browserTabSwitch.bind(this.browserCommands) browserHover: RuntimeBrowserCommands['browserHover'] = this.browserCommands.browserHover.bind( this.browserCommands ) browserDrag: RuntimeBrowserCommands['browserDrag'] = this.browserCommands.browserDrag.bind( this.browserCommands ) browserUpload: RuntimeBrowserCommands['browserUpload'] = this.browserCommands.browserUpload.bind( this.browserCommands ) browserWait: RuntimeBrowserCommands['browserWait'] = this.browserCommands.browserWait.bind( this.browserCommands ) browserCheck: RuntimeBrowserCommands['browserCheck'] = this.browserCommands.browserCheck.bind( this.browserCommands ) browserFocus: RuntimeBrowserCommands['browserFocus'] = this.browserCommands.browserFocus.bind( this.browserCommands ) browserClear: RuntimeBrowserCommands['browserClear'] = this.browserCommands.browserClear.bind( this.browserCommands ) browserSelectAll: RuntimeBrowserCommands['browserSelectAll'] = this.browserCommands.browserSelectAll.bind(this.browserCommands) browserKeypress: RuntimeBrowserCommands['browserKeypress'] = this.browserCommands.browserKeypress.bind(this.browserCommands) browserPdf: RuntimeBrowserCommands['browserPdf'] = this.browserCommands.browserPdf.bind( this.browserCommands ) browserFullScreenshot: RuntimeBrowserCommands['browserFullScreenshot'] = this.browserCommands.browserFullScreenshot.bind(this.browserCommands) browserCookieGet: RuntimeBrowserCommands['browserCookieGet'] = this.browserCommands.browserCookieGet.bind(this.browserCommands) browserCookieSet: RuntimeBrowserCommands['browserCookieSet'] = this.browserCommands.browserCookieSet.bind(this.browserCommands) browserCookieDelete: RuntimeBrowserCommands['browserCookieDelete'] = this.browserCommands.browserCookieDelete.bind(this.browserCommands) browserSetViewport: RuntimeBrowserCommands['browserSetViewport'] = this.browserCommands.browserSetViewport.bind(this.browserCommands) browserSetGeolocation: RuntimeBrowserCommands['browserSetGeolocation'] = this.browserCommands.browserSetGeolocation.bind(this.browserCommands) browserInterceptEnable: RuntimeBrowserCommands['browserInterceptEnable'] = this.browserCommands.browserInterceptEnable.bind(this.browserCommands) browserInterceptDisable: RuntimeBrowserCommands['browserInterceptDisable'] = this.browserCommands.browserInterceptDisable.bind(this.browserCommands) browserInterceptList: RuntimeBrowserCommands['browserInterceptList'] = this.browserCommands.browserInterceptList.bind(this.browserCommands) browserCaptureStart: RuntimeBrowserCommands['browserCaptureStart'] = this.browserCommands.browserCaptureStart.bind(this.browserCommands) browserCaptureStop: RuntimeBrowserCommands['browserCaptureStop'] = this.browserCommands.browserCaptureStop.bind(this.browserCommands) browserConsoleLog: RuntimeBrowserCommands['browserConsoleLog'] = this.browserCommands.browserConsoleLog.bind(this.browserCommands) browserNetworkLog: RuntimeBrowserCommands['browserNetworkLog'] = this.browserCommands.browserNetworkLog.bind(this.browserCommands) browserDblclick: RuntimeBrowserCommands['browserDblclick'] = this.browserCommands.browserDblclick.bind(this.browserCommands) browserForward: RuntimeBrowserCommands['browserForward'] = this.browserCommands.browserForward.bind(this.browserCommands) browserScrollIntoView: RuntimeBrowserCommands['browserScrollIntoView'] = this.browserCommands.browserScrollIntoView.bind(this.browserCommands) browserGet: RuntimeBrowserCommands['browserGet'] = this.browserCommands.browserGet.bind( this.browserCommands ) browserIs: RuntimeBrowserCommands['browserIs'] = this.browserCommands.browserIs.bind( this.browserCommands ) browserKeyboardInsertText: RuntimeBrowserCommands['browserKeyboardInsertText'] = this.browserCommands.browserKeyboardInsertText.bind(this.browserCommands) browserMouseMove: RuntimeBrowserCommands['browserMouseMove'] = this.browserCommands.browserMouseMove.bind(this.browserCommands) browserMouseDown: RuntimeBrowserCommands['browserMouseDown'] = this.browserCommands.browserMouseDown.bind(this.browserCommands) browserMouseClick: RuntimeBrowserCommands['browserMouseClick'] = this.browserCommands.browserMouseClick.bind(this.browserCommands) browserMouseUp: RuntimeBrowserCommands['browserMouseUp'] = this.browserCommands.browserMouseUp.bind(this.browserCommands) browserMouseWheel: RuntimeBrowserCommands['browserMouseWheel'] = this.browserCommands.browserMouseWheel.bind(this.browserCommands) browserFind: RuntimeBrowserCommands['browserFind'] = this.browserCommands.browserFind.bind( this.browserCommands ) browserSetDevice: RuntimeBrowserCommands['browserSetDevice'] = this.browserCommands.browserSetDevice.bind(this.browserCommands) browserSetOffline: RuntimeBrowserCommands['browserSetOffline'] = this.browserCommands.browserSetOffline.bind(this.browserCommands) browserSetHeaders: RuntimeBrowserCommands['browserSetHeaders'] = this.browserCommands.browserSetHeaders.bind(this.browserCommands) browserSetCredentials: RuntimeBrowserCommands['browserSetCredentials'] = this.browserCommands.browserSetCredentials.bind(this.browserCommands) browserSetMedia: RuntimeBrowserCommands['browserSetMedia'] = this.browserCommands.browserSetMedia.bind(this.browserCommands) browserClipboardRead: RuntimeBrowserCommands['browserClipboardRead'] = this.browserCommands.browserClipboardRead.bind(this.browserCommands) browserClipboardWrite: RuntimeBrowserCommands['browserClipboardWrite'] = this.browserCommands.browserClipboardWrite.bind(this.browserCommands) browserDialogAccept: RuntimeBrowserCommands['browserDialogAccept'] = this.browserCommands.browserDialogAccept.bind(this.browserCommands) browserDialogDismiss: RuntimeBrowserCommands['browserDialogDismiss'] = this.browserCommands.browserDialogDismiss.bind(this.browserCommands) browserStorageLocalGet: RuntimeBrowserCommands['browserStorageLocalGet'] = this.browserCommands.browserStorageLocalGet.bind(this.browserCommands) browserStorageLocalSet: RuntimeBrowserCommands['browserStorageLocalSet'] = this.browserCommands.browserStorageLocalSet.bind(this.browserCommands) browserStorageLocalClear: RuntimeBrowserCommands['browserStorageLocalClear'] = this.browserCommands.browserStorageLocalClear.bind(this.browserCommands) browserStorageSessionGet: RuntimeBrowserCommands['browserStorageSessionGet'] = this.browserCommands.browserStorageSessionGet.bind(this.browserCommands) browserStorageSessionSet: RuntimeBrowserCommands['browserStorageSessionSet'] = this.browserCommands.browserStorageSessionSet.bind(this.browserCommands) browserStorageSessionClear: RuntimeBrowserCommands['browserStorageSessionClear'] = this.browserCommands.browserStorageSessionClear.bind(this.browserCommands) browserDownload: RuntimeBrowserCommands['browserDownload'] = this.browserCommands.browserDownload.bind(this.browserCommands) browserHighlight: RuntimeBrowserCommands['browserHighlight'] = this.browserCommands.browserHighlight.bind(this.browserCommands) browserExec: RuntimeBrowserCommands['browserExec'] = this.browserCommands.browserExec.bind( this.browserCommands ) browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] = this.browserCommands.browserTabCreate.bind(this.browserCommands) browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] = this.browserCommands.browserTabSetProfile.bind(this.browserCommands) browserTabProfileShow: RuntimeBrowserCommands['browserTabProfileShow'] = this.browserCommands.browserTabProfileShow.bind(this.browserCommands) browserTabProfileClone: RuntimeBrowserCommands['browserTabProfileClone'] = this.browserCommands.browserTabProfileClone.bind(this.browserCommands) browserProfileList: RuntimeBrowserCommands['browserProfileList'] = this.browserCommands.browserProfileList.bind(this.browserCommands) browserProfileCreate: RuntimeBrowserCommands['browserProfileCreate'] = this.browserCommands.browserProfileCreate.bind(this.browserCommands) browserProfileDelete: RuntimeBrowserCommands['browserProfileDelete'] = this.browserCommands.browserProfileDelete.bind(this.browserCommands) browserProfileDetectBrowsers: RuntimeBrowserCommands['browserProfileDetectBrowsers'] = this.browserCommands.browserProfileDetectBrowsers.bind(this.browserCommands) browserProfileImportFromBrowser: RuntimeBrowserCommands['browserProfileImportFromBrowser'] = this.browserCommands.browserProfileImportFromBrowser.bind(this.browserCommands) browserProfileClearDefaultCookies: RuntimeBrowserCommands['browserProfileClearDefaultCookies'] = this.browserCommands.browserProfileClearDefaultCookies.bind(this.browserCommands) browserTabClose: RuntimeBrowserCommands['browserTabClose'] = this.browserCommands.browserTabClose.bind(this.browserCommands) // Emulator bindings (delegated to dedicated commands for surface separation). emulatorTap: RuntimeEmulatorCommands['emulatorTap'] = this.emulatorCommands.emulatorTap.bind( this.emulatorCommands ) emulatorGesture: RuntimeEmulatorCommands['emulatorGesture'] = this.emulatorCommands.emulatorGesture.bind(this.emulatorCommands) emulatorType: RuntimeEmulatorCommands['emulatorType'] = this.emulatorCommands.emulatorType.bind( this.emulatorCommands ) emulatorButton: RuntimeEmulatorCommands['emulatorButton'] = this.emulatorCommands.emulatorButton.bind(this.emulatorCommands) emulatorRotate: RuntimeEmulatorCommands['emulatorRotate'] = this.emulatorCommands.emulatorRotate.bind(this.emulatorCommands) emulatorExec: RuntimeEmulatorCommands['emulatorExec'] = this.emulatorCommands.emulatorExec.bind( this.emulatorCommands ) emulatorAttach: RuntimeEmulatorCommands['emulatorAttach'] = this.emulatorCommands.emulatorAttach.bind(this.emulatorCommands) emulatorList: RuntimeEmulatorCommands['emulatorList'] = this.emulatorCommands.emulatorList.bind( this.emulatorCommands ) emulatorKill: RuntimeEmulatorCommands['emulatorKill'] = this.emulatorCommands.emulatorKill.bind( this.emulatorCommands ) emulatorShutdown: RuntimeEmulatorCommands['emulatorShutdown'] = this.emulatorCommands.emulatorShutdown.bind(this.emulatorCommands) emulatorListSimulators: RuntimeEmulatorCommands['emulatorListSimulators'] = this.emulatorCommands.emulatorListSimulators.bind(this.emulatorCommands) emulatorAvailability: RuntimeEmulatorCommands['emulatorAvailability'] = this.emulatorCommands.emulatorAvailability.bind(this.emulatorCommands) emulatorListDevices: RuntimeEmulatorCommands['emulatorListDevices'] = this.emulatorCommands.emulatorListDevices.bind(this.emulatorCommands) emulatorInstall: RuntimeEmulatorCommands['emulatorInstall'] = this.emulatorCommands.emulatorInstall.bind(this.emulatorCommands) emulatorLaunch: RuntimeEmulatorCommands['emulatorLaunch'] = this.emulatorCommands.emulatorLaunch.bind(this.emulatorCommands) emulatorPermissions: RuntimeEmulatorCommands['emulatorPermissions'] = this.emulatorCommands.emulatorPermissions.bind(this.emulatorCommands) emulatorAx: RuntimeEmulatorCommands['emulatorAx'] = this.emulatorCommands.emulatorAx.bind( this.emulatorCommands ) emulatorLogcat: RuntimeEmulatorCommands['emulatorLogcat'] = this.emulatorCommands.emulatorLogcat.bind(this.emulatorCommands) emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] = this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands) private getAuthoritativeWindow(): BrowserWindow { const win = this.getAvailableAuthoritativeWindow() if (!win || win.isDestroyed()) { throw new Error('No renderer window available') } return win } private getAvailableAuthoritativeWindow(): BrowserWindow | null { if (this.authoritativeWindowId === null) { return null } if (!BrowserWindow?.fromId) { return null } const win = BrowserWindow.fromId(this.authoritativeWindowId) return win && !win.isDestroyed() ? win : null } } const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 // Why: chunks that could complete an actionable prompt bypass the throttle so blocked stamps stay immediate; scanned over the new chunk + short carry, never the whole window. const WAIT_BLOCKED_KEYWORD_PATTERN = /press enter|press t to trust|do you trust|trust this|trusted workspace|update available|choose working directory|codex just got an upgrade|hooks need review/ const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 const MAX_TAIL_LINES = 2000 const MAX_TAIL_CHARS = 256 * 1024 const MAX_TAIL_PARTIAL_CHARS = 4000 const MAX_TAIL_PENDING_ANSI_CHARS = 4096 const DEFAULT_TERMINAL_READ_LIMIT = 120 const MAX_TERMINAL_READ_LIMIT = 2000 const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024 export const AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 8_000 const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750 const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000 const MAX_PREVIEW_LINES = 6 const MAX_PREVIEW_CHARS = 300 const WORKTREE_STATUS_PRIORITY: Record = { inactive: 0, active: 1, done: 2, working: 3, permission: 4 } const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25 const DEFAULT_TERMINAL_LIST_LIMIT = 200 const DEFAULT_WORKTREE_LIST_LIMIT = 200 const DEFAULT_WORKTREE_PS_LIMIT = 200 const DISCONNECTED_PTY_RECORD_MAX = 128 const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 // Why: agent-scratch repos don't need 30s freshness — the steady-state scan // fan-out was measured at ~128 git execs/min on real installs, mostly against // these (crash-cluster diagnostics, 2026-07). const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) ? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS : WORKTREE_SCAN_CACHE_TTL_MS } const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 // Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out. const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000 async function waitForWorktreeTerminalMutation( previous: Promise, deadline?: number ): Promise { if (deadline === undefined) { await previous return } const remainingMs = deadline - Date.now() if (remainingMs <= 0) { throw new Error('terminal_worktree_sleep_timeout') } let timeout: ReturnType | undefined try { await Promise.race([ previous, new Promise((_, reject) => { timeout = setTimeout( () => reject(new Error('terminal_worktree_sleep_timeout')), remainingMs ) }) ]) } finally { if (timeout !== undefined) { clearTimeout(timeout) } } } // Why: listener fan-out is best-effort delivery. One subscriber throwing synchronously — e.g. a // paired-client relay whose stream is closed — must never abort the emitting operation or leak // state (a lock/mutation) the caller holds across the emit. Isolate every listener and log. function notifyRuntimeListeners( listeners: Iterable, deliver: (listener: L) => void, context: string ): void { for (const listener of listeners) { try { deliver(listener) } catch (error) { console.error(`[runtime] ${context} listener threw`, error) } } } // Why (§3.3): 30s freshness window reuses a recent fetch for repeat create/dispatch on the same repo+remote; short enough a changed remote is seen next action. const FETCH_FRESHNESS_MS = 30_000 // Why: bound fetches so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation; parity with the exact-base refresh sibling. const REMOTE_FETCH_TIMEOUT_MS = 60_000 const REMOTE_FETCH_CACHE_MAX = 512 const DRIFT_PROBE_SUBJECT_LIMIT = 5 function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { if (map.has(key)) { map.delete(key) } map.set(key, value) while (map.size > maxEntries) { const oldest = map.keys().next() if (oldest.done) { return } map.delete(oldest.value) } } function getExplicitWorktreeIdSelector(selector: string | undefined): string | null { if (!selector?.startsWith('id:')) { return null } const id = selector.slice(3) return id.length > 0 ? id : null } function withTimeout(promise: Promise, timeoutMs: number, fallback: T): Promise { let timeout: ReturnType | null = null return new Promise((resolve) => { timeout = setTimeout(() => resolve(fallback), timeoutMs) promise.then( (value) => resolve(value), () => resolve(fallback) ) }).finally(() => { if (timeout) { clearTimeout(timeout) } }) } function withTimeoutResult( promise: Promise, timeoutMs: number ): Promise<{ ok: true; value: T } | { ok: false }> { return withTimeout( promise.then((value) => ({ ok: true, value }) as const), timeoutMs, { ok: false } ) } export function appendRecentPtyPathCandidates( previous: string[] | undefined, data: string ): string[] { const extractedCandidates = extractTerminalOutputPathCandidates(data) if (extractedCandidates.length === 0) { // Why: pathless output is the hot path; reuse immutable history so each chunk doesn't clone and byte-scan up to 1,024 old candidates. return previous ?? [] } const next = previous ? previous.slice() : [] for (const candidate of extractedCandidates) { if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { continue } next.push(candidate) } return pruneRecentPtyPathCandidates(next) } export function recentTerminalPathCandidatesIncludePath( recentCandidates: readonly string[], pathText: string, absolutePath: string ): boolean { const candidates = new Set( [ pathText, absolutePath, ...wslTerminalOutputAliases(pathText), ...wslTerminalOutputAliases(absolutePath) ] .map((candidate) => candidate.trim()) .filter((candidate) => candidate.length > 0) ) for (const recent of recentCandidates) { if (candidates.has(recent)) { return true } } return false } function pruneRecentPtyPathCandidates(candidates: string[]): string[] { const countBounded = candidates.length > RECENT_PTY_PATH_CANDIDATE_LIMIT ? candidates.slice(-RECENT_PTY_PATH_CANDIDATE_LIMIT) : candidates let totalBytes = 0 let startIndex = countBounded.length for (let index = countBounded.length - 1; index >= 0; index -= 1) { const nextTotal = totalBytes + Buffer.byteLength(countBounded[index]!, 'utf8') if (nextTotal > RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES) { break } totalBytes = nextTotal startIndex = index } return startIndex === 0 ? countBounded : countBounded.slice(startIndex) } export function recentTerminalOutputIncludesPath( recentOutput: string, pathText: string, absolutePath: string ): boolean { const candidates = new Set( [pathText, absolutePath] .map((candidate) => candidate.trim()) .filter((candidate) => candidate.length > 0) ) if (candidates.size === 0) { return false } for (const candidate of candidates) { if (outputContainsPathCandidate(recentOutput, candidate)) { return true } } const decodedOutput = decodeTerminalOutputPercentEscapes(recentOutput) if (decodedOutput !== recentOutput) { for (const candidate of candidates) { if (outputContainsPathCandidate(decodedOutput, candidate)) { return true } } } return false } function outputContainsPathCandidate(output: string, candidate: string): boolean { let start = output.indexOf(candidate) while (start !== -1) { const end = start + candidate.length if (isPathCandidateStartBoundary(output, start) && isPathCandidateEndBoundary(output, end)) { return true } start = output.indexOf(candidate, start + 1) } return false } function isPathCandidateStartBoundary(output: string, start: number): boolean { if (start === 0) { return true } if (output.slice(0, start).endsWith('file://')) { return true } if ( /^[A-Za-z]:[\\/]/.test(output.slice(start)) && /file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)?\/$/i.test(output.slice(0, start)) ) { return true } if (/file:\/\/(?:localhost|127\.0\.0\.1|\[?::1\]?)$/i.test(output.slice(0, start))) { return true } return !isPathCandidateContinuationChar(output[start - 1]!) } function isPathCandidateEndBoundary(output: string, end: number): boolean { const next = output[end] if (!next) { return true } if (next === ':' && /^\d+(?::\d+)?(?:\D|$)/.test(output.slice(end + 1))) { return true } return !isPathCandidateContinuationChar(next) } function isPathCandidateContinuationChar(char: string): boolean { return /[A-Za-z0-9._~/%+@\\()[\]-]/.test(char) } function decodeTerminalOutputPercentEscapes(value: string): string { return value.replace(/(?:%[0-9a-f]{2})+/gi, (match) => { try { return decodeURIComponent(match) } catch { return match } }) } // Why: the extension regex backtracks quadratically on the PTY hot path; candidates can't cross a newline, so scan per line and skip over-long lines (dropped anyway). function extractTerminalOutputPathCandidates(data: string): string[] { const candidates: string[] = [] const add = (value: string): void => { const candidate = trimTerminalOutputPathCandidate(value) if (candidate.length > 0) { candidates.push(candidate) const drivePath = normalizeTerminalOutputFileUriDrivePath(candidate) if (drivePath) { candidates.push(drivePath) } } } for (const line of data.split(/[\r\n]+/)) { if (line.length === 0 || line.length > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { continue } collectTerminalOutputLinePathCandidates(line, add) } return candidates } function collectTerminalOutputLinePathCandidates(line: string, add: (value: string) => void): void { for (const match of line.matchAll(/file:\/\/([^/\s]*)(\/[^\s\x1b"'<>)]*)/gi)) { const authority = match[1] ?? '' const uriPath = match[2] if (uriPath) { const decoded = decodeTerminalOutputPercentEscapes(uriPath) add(isTerminalOutputLoopbackAuthority(authority) ? decoded : `//${authority}${decoded}`) } } for (const match of line.matchAll( /(?:\/(?:tmp|private\/tmp)\/|[A-Za-z]:[\\/])[^\r\n\x1b"'<>]+/g )) { if (isInsideNonLocalFileUri(line, match.index)) { continue } add(match[0]) } for (const match of line.matchAll( /\/[^\r\n\x1b"'<>]*\.[A-Za-z0-9_+-]+(?:[#:\s][^\r\n\x1b"'<>]*)?/g )) { if (isInsideNonLocalFileUri(line, match.index)) { continue } add(match[0]) } } function normalizeTerminalOutputFileUriDrivePath(candidate: string): string | null { return /^\/[A-Za-z]:[\\/]/.test(candidate) ? candidate.slice(1) : null } function trimTerminalOutputPathCandidate(value: string): string { let candidate = value.trim().replace(/[),;.]+$/g, '') if (Buffer.byteLength(candidate, 'utf8') > RECENT_PTY_PATH_CANDIDATE_MAX_BYTES) { return '' } let selected: string | null = null for (const match of candidate.matchAll( /.+?\.[A-Za-z0-9_+-]+(?:#L\d+(?:C\d+)?|(?::\d+)?(?::\d+)?)?(?=\s+|$)/gi )) { const end = match.index + match[0].length const text = candidate.slice(0, end) if (countTerminalOutputPathStarts(text) > 1) { continue } // Same as the tap parsers: a line-end token extends the candidate only when the added segment is path-like, so trailing prose isn't swallowed. if ( end < candidate.length || selected === null || /[\\/]/.test(candidate.slice(selected.length, end)) ) { selected = text } } return trimTerminalOutputPathLocator(selected ?? candidate) } function isTerminalOutputLoopbackAuthority(authority: string): boolean { const normalized = authority.toLowerCase() return ( normalized === '' || normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]' ) } function isInsideNonLocalFileUri(output: string, pathStart: number): boolean { const prefix = output.slice(0, pathStart) const match = /file:\/\/([^/\s]*)$/i.exec(prefix) return !!match && !isTerminalOutputLoopbackAuthority(match[1] ?? '') } function countTerminalOutputPathStarts(value: string): number { let count = 0 for (const match of value.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) { void match count += 1 } return count } function trimTerminalOutputPathLocator(value: string): string { return value.replace(/#L\d+(?:C\d+)?$/i, '').replace(/:\d+(?::\d+)?$/, '') } function wslTerminalOutputAliases(value: string): string[] { const match = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+(\\.*)$/i.exec(value) if (!match) { return [] } const linuxPath = match[1]!.replace(/\\/g, '/') return linuxPath.startsWith('/') ? [linuxPath] : [`/${linuxPath}`] } export function buildPreview(lines: string[], partialLine: string): string { const previewLines: string[] = [] const collectVisibleLine = (line: string): void => { const trimmed = line.trim() if (trimmed.length > 0) { previewLines.push(trimmed) } } if (partialLine.length > 0) { collectVisibleLine(partialLine) } for ( let index = lines.length - 1; index >= 0 && previewLines.length < MAX_PREVIEW_LINES; index-- ) { collectVisibleLine(lines[index]) } previewLines.reverse() const preview = previewLines.join('\n') return preview.length > MAX_PREVIEW_CHARS ? preview.slice(preview.length - MAX_PREVIEW_CHARS) : preview } // Why: restore payloads can be multi-MB; the records only retain a bounded tail, // so cap the one-time parse on the spawn path to the suffix that can matter. const MAX_RESTORE_TAIL_SEED_CHARS = 256 * 1024 type RestoredTerminalTailSeed = { lines: string[] transcriptLines: string[] transcriptChars: number partialLine: string pendingAnsi: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean linesTotal: number preview: string } type RestorableTerminalTailRecord = Pick< RuntimePtyWorktreeRecord, | 'lastOutputAt' | 'tailBuffer' | 'tailTranscriptBuffer' | 'tailTranscriptChars' | 'tailPartialLine' | 'tailPendingAnsi' | 'tailRedrawCursor' | 'tailTruncated' | 'tailLinesTotal' | 'preview' > export function buildRestoredTerminalTailSeed(text: string): RestoredTerminalTailSeed | null { let bounded = text let sliced = false if (bounded.length > MAX_RESTORE_TAIL_SEED_CHARS) { bounded = bounded.slice(-MAX_RESTORE_TAIL_SEED_CHARS) // Why: an arbitrary suffix can start mid-escape; restarting after the first // line break resumes at a boundary (escape params never span \n or \r — // \r covers newline-free CR-redraw streams). Consume a full \r\n pair so // the seed does not begin with a phantom blank line. const anchor = bounded.search(/[\r\n]/) if (anchor !== -1) { bounded = bounded.slice( bounded[anchor] === '\r' && bounded[anchor + 1] === '\n' ? anchor + 2 : anchor + 1 ) } sliced = true } // Why: the live-path pipeline, so seeded records equal what streaming the // same bytes through onPtyData would have produced. const normalized = normalizeTerminalChunk(bounded) const tail = appendNormalizedToTailBuffer([], '', normalized.text, null) if (tail.lines.length === 0 && tail.partialLine.length === 0) { return null } const transcript = appendCompletedTerminalTranscript( [], 0, tail.newlyCompletedLines, tail.newCompleteLines ) return { lines: tail.lines, transcriptLines: transcript.lines, transcriptChars: transcript.characters, partialLine: tail.partialLine, pendingAnsi: normalized.pendingAnsi, redrawCursor: tail.redrawCursor, truncated: sliced || tail.truncated || transcript.truncated, linesTotal: tail.newCompleteLines, preview: buildPreview(tail.lines, tail.partialLine) } } function restoredTerminalTailSeedAllowed(record: RestorableTerminalTailRecord): boolean { return ( record.lastOutputAt === null && record.preview.length === 0 && record.tailBuffer.length === 0 && record.tailPartialLine.length === 0 ) } // Deliberately untouched: lastOutputAt (historical bytes must not read as fresh // activity) and waitBlockedAt/tailWaitState (a restored prompt is not a live // wait signal; the next live chunk recomputes both from this seeded tail). function applyRestoredTerminalTailSeed( record: RestorableTerminalTailRecord, seed: RestoredTerminalTailSeed ): void { // Why shared instances: append helpers never mutate prior arrays, and equal // references let tailStateMatches keep its O(1) leaf/pty reuse fast path. record.tailBuffer = seed.lines record.tailTranscriptBuffer = seed.transcriptLines record.tailTranscriptChars = seed.transcriptChars record.tailPartialLine = seed.partialLine record.tailPendingAnsi = seed.pendingAnsi record.tailRedrawCursor = seed.redrawCursor record.tailTruncated = seed.truncated record.tailLinesTotal = seed.linesTotal record.preview = seed.preview } function buildTerminalWaitText(lines: string[], partialLine: string, preview: string): string { const waitText = buildTailLines(lines, partialLine) .map((line) => line.trim()) .filter(Boolean) .join('\n') // Why: the preview is intentionally short, but wait readiness needs the retained tail so ready headers aren't truncated away. return waitText.length > 0 ? waitText : preview } export type TerminalTailWaitState = { waitText: string signal: { reason: RuntimeTerminalWaitBlockedReason; index: number } | null // Why: preview is only an empty-tail fallback, recomputed each append, so a preview-derived state can't be reused as the next previous state (gated on fromTail). fromTail: boolean } // Why: runs per PTY chunk (hundreds/sec); only candidate-bearing tails parse the full 256 KiB, and the cached state avoids repeating that work next chunk. export function computeTerminalTailWaitState( lines: string[], partialLine: string, preview: string ): TerminalTailWaitState { const tailShape = inspectTerminalWaitTail(lines, partialLine) if (!tailShape.fromTail) { return { waitText: preview, signal: findActionableTerminalWaitBlockedSignal(preview.toLowerCase()), fromTail: false } } if (!tailShape.mayContainBlockedSignal) { // Why: reads waitText only when a signal exists; avoid retaining a rebuilt 256 KiB string in the common case. return { waitText: '', signal: null, fromTail: true } } const tailText = buildTailLines(lines, partialLine) .map((line) => line.trim()) .filter(Boolean) .join('\n') const fromTail = tailText.length > 0 const waitText = fromTail ? tailText : preview return { waitText, signal: findActionableTerminalWaitBlockedSignal(waitText.toLowerCase()), fromTail } } function inspectTerminalWaitTail( lines: string[], partialLine: string ): { fromTail: boolean; mayContainBlockedSignal: boolean } { let fromTail = false let mayContainBlockedSignal = false for (const line of lines) { if (!fromTail && line.trim().length > 0) { fromTail = true } if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { mayContainBlockedSignal = true } } if (!fromTail && partialLine.trim().length > 0) { fromTail = true } if (!mayContainBlockedSignal && TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine)) { mayContainBlockedSignal = true } return { fromTail, mayContainBlockedSignal } } // Why: consumes precomputed wait states so full-tail scans aren't repeated per chunk (replaces the former inline double full-tail scan). export function tailGainedNewerBlockedReason( previous: TerminalTailWaitState, next: TerminalTailWaitState, appendedText: string ): boolean { if (next.signal === null) { return false } // Why: permission prompts can split across PTY chunks; stamp when the tail first becomes blocked, or a later prompt follows stale blocked text. if (previous.signal === null) { return true } const appendCandidateSignal = findActionableTerminalWaitBlockedSignal( `${previous.waitText}${appendedText}`.toLowerCase() ) return appendCandidateSignal !== null && appendCandidateSignal.index > previous.signal.index } export function appendNormalizedToTailBuffer( previousLines: string[], previousPartialLine: string, normalizedChunk: string, previousRedrawCursor: RetainedTailRedrawCursor | null = null ): { lines: string[] partialLine: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number newlyCompletedLines: string[] } { if (normalizedChunk.length === 0) { return { lines: previousLines, partialLine: previousPartialLine, redrawCursor: previousRedrawCursor, truncated: false, newCompleteLines: 0, newlyCompletedLines: [] } } // Why: fullscreen TUIs emit long newline-free redraw streams; keep the line transcript for pagination but bound partial-line work. const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) const combinedChunk = `${boundedPreviousPartialLine}${normalizedChunk}` if (previousRedrawCursor || containsTerminalVerticalLineControl(combinedChunk)) { return appendNormalizedToMultilineTailBuffer( previousLines, boundedPreviousPartialLine, normalizedChunk, previousPartialWasCapped, previousRedrawCursor ) } // Why: status UIs redraw one line via CR/backspace/erase; retain the latest redraw segment instead of appending every spinner frame. const segments = splitRetainedTerminalTailSegments(combinedChunk) const pieces = processTerminalTailCompleteSegments(segments.completeSegments) const newlyCompletedLines = pieces.map((line) => trimTerminalLineRight(line)) const partialResult = applyTerminalLineControls(segments.partialSegment) const nextPartialLine = trimTerminalLineRight(partialResult.text) const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS) const newCompleteLines = segments.completeLineCount const omittedNewCompleteLines = newCompleteLines - pieces.length let nextLines = newCompleteLines > 0 ? [...(omittedNewCompleteLines > 0 ? [] : previousLines), ...newlyCompletedLines] : previousLines let truncated = previousPartialWasCapped || omittedNewCompleteLines > 0 || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS if (nextLines.length > MAX_TAIL_LINES) { nextLines = nextLines.slice(nextLines.length - MAX_TAIL_LINES) truncated = true } if (newCompleteLines > 0 || retainedPartialLine.length > previousPartialLine.length) { if (nextLines === previousLines) { nextLines = [...previousLines] } let totalChars = nextLines.reduce((sum, line) => sum + line.length, 0) + retainedPartialLine.length let trimStartIndex = 0 while (trimStartIndex < nextLines.length && totalChars > MAX_TAIL_CHARS) { totalChars -= nextLines[trimStartIndex].length trimStartIndex += 1 } if (trimStartIndex > 0) { nextLines = nextLines.slice(trimStartIndex) truncated = true } } const redrawCursor = !partialResult.hadControl || partialResult.cursorColumn === nextPartialLine.length ? null : { rowFromEnd: 0, column: partialResult.cursorColumn } return { lines: nextLines, partialLine: retainedPartialLine, redrawCursor, truncated, newCompleteLines, newlyCompletedLines } } function trimTerminalLineRight(line: string): string { let end = line.length while (end > 0) { const code = line.charCodeAt(end - 1) if (code !== 0x20 && code !== 0x09) { break } end -= 1 } return end === line.length ? line : line.slice(0, end) } // Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts. const REDRAW_WINDOW_SAFETY_ROWS = 8 function maxUpwardCursorReach( normalizedChunk: string, previousRedrawCursor: RetainedTailRedrawCursor | null ): number { let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 const cursorUpPattern = /\x1b\[(\d*)(?:;[\d;]*)?A/g let match: RegExpExecArray | null while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) { reach += match[1] ? Number.parseInt(match[1], 10) : 1 } return reach } function appendNormalizedToMultilineTailBuffer( previousLines: string[], boundedPreviousPartialLine: string, normalizedChunk: string, previousPartialWasCapped: boolean, previousRedrawCursor: RetainedTailRedrawCursor | null ): { lines: string[] partialLine: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number newlyCompletedLines: string[] } { const windowRows = maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS if (windowRows >= previousLines.length) { return appendNormalizedToMultilineTailBufferUnwindowed( previousLines, boundedPreviousPartialLine, normalizedChunk, previousPartialWasCapped, previousRedrawCursor ) } const prefixLength = previousLines.length - windowRows const suffix = previousLines.slice(prefixLength) const windowed = appendNormalizedToMultilineTailBufferUnwindowed( suffix, boundedPreviousPartialLine, normalizedChunk, previousPartialWasCapped, previousRedrawCursor ) let lines = previousLines.slice(0, prefixLength) // Why: the shared prefix must match the unwindowed finalize's trailing-space trim without paying a regex per untouched row. for (let index = 0; index < lines.length; index += 1) { const line = lines[index]! const lastChar = line.charCodeAt(line.length - 1) if (lastChar === 32 || lastChar === 9) { lines[index] = line.replace(/[ \t]+$/g, '') } } for (const line of windowed.lines) { lines.push(line) } let truncated = windowed.truncated if (lines.length > MAX_TAIL_LINES) { lines = lines.slice(lines.length - MAX_TAIL_LINES) truncated = true } let totalChars = windowed.partialLine.length for (const line of lines) { totalChars += line.length } let dropCount = 0 while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { totalChars -= lines[dropCount]!.length dropCount += 1 } if (dropCount > 0) { lines = lines.slice(dropCount) truncated = true } return { lines, partialLine: windowed.partialLine, redrawCursor: windowed.redrawCursor, truncated, newCompleteLines: windowed.newCompleteLines, newlyCompletedLines: windowed.newlyCompletedLines } } export function appendNormalizedToMultilineTailBufferUnwindowed( previousLines: string[], boundedPreviousPartialLine: string, normalizedChunk: string, previousPartialWasCapped: boolean, previousRedrawCursor: RetainedTailRedrawCursor | null ): { lines: string[] partialLine: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number newlyCompletedLines: string[] } { const rows: RetainedTerminalRow[] = [ ...previousLines.map((line) => ({ text: line, completed: true })), { text: boundedPreviousPartialLine, completed: false } ] let cursorRow = previousRedrawCursor ? Math.max(0, rows.length - 1 - previousRedrawCursor.rowFromEnd) : rows.length - 1 let cursorColumn = previousRedrawCursor?.column ?? boundedPreviousPartialLine.length let newCompleteLines = 0 const newlyCompletedLines: string[] = [] let newlyCompletedLineCharacters = 0 let newlyCompletedLineStart = 0 let truncated = previousPartialWasCapped const retainNewlyCompletedLine = (line: string): void => { newlyCompletedLines.push(line) newlyCompletedLineCharacters += line.length while ( newlyCompletedLines.length - newlyCompletedLineStart > MAX_TAIL_LINES || newlyCompletedLineCharacters > MAX_TAIL_CHARS ) { newlyCompletedLineCharacters -= newlyCompletedLines[newlyCompletedLineStart]!.length newlyCompletedLineStart += 1 } // Why: a single PTY chunk can carry unbounded newlines; compact in batches while retaining the suffix needed for stable pagination. if (newlyCompletedLineStart >= MAX_TAIL_LINES) { newlyCompletedLines.splice(0, newlyCompletedLineStart) newlyCompletedLineStart = 0 } } const ensureCursorRow = (): void => { while (cursorRow >= rows.length) { rows.push({ text: '', completed: false }) } } const trimRows = (): void => { const maxRows = MAX_TAIL_LINES + 1 if (rows.length <= maxRows) { return } const removeCount = rows.length - maxRows rows.splice(0, removeCount) cursorRow = Math.max(0, cursorRow - removeCount) truncated = true } const moveCursorToColumn = (nextColumn: number): void => { cursorColumn = clampTerminalPreviewCursor(nextColumn) } const markCursorRowRewritten = (): void => { ensureCursorRow() rows[cursorRow]!.completed = false } const writeChar = (char: string): void => { ensureCursorRow() markCursorRowRewritten() const row = rows[cursorRow]! if (cursorColumn > row.text.length) { row.text = `${row.text}${' '.repeat(cursorColumn - row.text.length)}` } row.text = cursorColumn >= row.text.length ? `${row.text}${char}` : `${row.text.slice(0, cursorColumn)}${char}${row.text.slice(cursorColumn + 1)}` cursorColumn += 1 } const eraseLine = (mode: number): void => { ensureCursorRow() markCursorRowRewritten() const row = rows[cursorRow]! if (mode === 0) { row.text = row.text.slice(0, cursorColumn) } else if (mode === 1) { const deleteCount = Math.min(cursorColumn + 1, row.text.length) row.text = `${' '.repeat(deleteCount)}${row.text.slice(deleteCount)}` } else if (mode === 2) { row.text = '' } } for (let index = 0; index < normalizedChunk.length; index += 1) { const char = normalizedChunk[index] if (char === '\n') { ensureCursorRow() rows[cursorRow]!.completed = true newCompleteLines += 1 retainNewlyCompletedLine(trimTerminalLineRight(rows[cursorRow]!.text)) cursorRow += 1 cursorColumn = 0 ensureCursorRow() trimRows() continue } if (char === '\r') { cursorColumn = 0 continue } if (char === '\u0008') { cursorColumn = Math.max(0, cursorColumn - 1) continue } if (char === '\u001b') { const parsed = parseAnsiControlSequence(normalizedChunk, index) if (!parsed) { continue } index = parsed.endIndex if (parsed.kind !== 'csi' || !hasCanonicalNumericCsiParams(parsed.params)) { continue } const firstParam = parsed.firstParam ?? 1 if (parsed.final === 'A') { cursorRow = Math.max(0, cursorRow - firstParam) rows.splice(cursorRow + 1) } else if (parsed.final === 'K') { eraseLine(parsed.firstParam ?? 0) } else if (parsed.final === 'G' || parsed.final === '`') { moveCursorToColumn(firstParam - 1) } else if (parsed.final === 'D') { cursorColumn = Math.max(0, cursorColumn - firstParam) } else if (parsed.final === 'C') { moveCursorToColumn(cursorColumn + firstParam) } continue } writeChar(char) } return finalizeRetainedTerminalRows( rows, cursorRow, cursorColumn, truncated, newCompleteLines, newlyCompletedLineStart > 0 ? newlyCompletedLines.slice(newlyCompletedLineStart) : newlyCompletedLines ) } type RetainedTailRedrawCursor = { rowFromEnd: number column: number } type RetainedTerminalRow = { text: string completed: boolean } function finalizeRetainedTerminalRows( rows: RetainedTerminalRow[], cursorRow: number, cursorColumn: number, initialTruncated: boolean, newCompleteLines: number, newlyCompletedLines: string[] ): { lines: string[] partialLine: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number newlyCompletedLines: string[] } { let truncated = initialTruncated let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') })) if (retainedRows.length > MAX_TAIL_LINES + 1) { const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1) retainedRows = retainedRows.slice(removeCount) cursorRow = Math.max(0, cursorRow - removeCount) truncated = true } let totalChars = retainedRows.reduce((sum, row) => sum + row.text.length, 0) let trimStartIndex = 0 while (trimStartIndex < retainedRows.length - 1 && totalChars > MAX_TAIL_CHARS) { totalChars -= retainedRows[trimStartIndex]!.text.length trimStartIndex += 1 } if (trimStartIndex > 0) { retainedRows = retainedRows.slice(trimStartIndex) cursorRow = Math.max(0, cursorRow - trimStartIndex) truncated = true } while ( retainedRows.length > 1 && cursorRow < retainedRows.length - 1 && retainedRows.at(-1)?.completed === false && retainedRows.at(-1)?.text.length === 0 ) { retainedRows.pop() } const lastRow = retainedRows.at(-1) let partialLine = lastRow && !lastRow.completed ? lastRow.text : '' let lines = (lastRow && !lastRow.completed ? retainedRows.slice(0, -1) : retainedRows).map( (row) => row.text ) if (partialLine.length > MAX_TAIL_PARTIAL_CHARS) { partialLine = partialLine.slice(-MAX_TAIL_PARTIAL_CHARS) truncated = true } if (lines.length > MAX_TAIL_LINES) { lines = lines.slice(lines.length - MAX_TAIL_LINES) truncated = true } const outputRowCount = lines.length + 1 const defaultCursorRow = outputRowCount - 1 const defaultCursorColumn = partialLine.length const redrawCursor = cursorRow === defaultCursorRow && cursorColumn === defaultCursorColumn ? null : { rowFromEnd: Math.max(0, outputRowCount - 1 - cursorRow), column: clampTerminalPreviewCursor(cursorColumn) } return { lines, partialLine, redrawCursor, truncated, newCompleteLines, newlyCompletedLines } } function splitRetainedTerminalTailSegments(value: string): { completeSegments: string[] partialSegment: string completeLineCount: number } { let completeLineCount = 0 for (let index = 0; index < value.length; index += 1) { if (value[index] === '\n') { completeLineCount += 1 } } const retainedCompleteCount = Math.min(completeLineCount, MAX_TAIL_LINES) const omittedCompleteCount = completeLineCount - retainedCompleteCount let startIndex = 0 if (omittedCompleteCount > 0) { let seen = 0 for (let index = 0; index < value.length; index += 1) { if (value[index] !== '\n') { continue } seen += 1 if (seen === omittedCompleteCount) { startIndex = index + 1 break } } } const completeSegments: string[] = [] let segmentStart = startIndex for (let index = startIndex; index < value.length; index += 1) { if (value[index] !== '\n') { continue } completeSegments.push(value.slice(segmentStart, index)) segmentStart = index + 1 } return { completeSegments, partialSegment: value.slice(segmentStart), completeLineCount } } function processTerminalTailCompleteSegments(segments: string[]): string[] { const processed: string[] = [] let totalChars = 0 for (let index = segments.length - 1; index >= 0; index -= 1) { const line = applyTerminalLineControls(segments[index]!).text processed.push(line) totalChars += line.length if (totalChars > MAX_TAIL_CHARS) { break } } processed.reverse() return processed } function applyTerminalLineControls(line: string): { text: string cursorColumn: number hadControl: boolean } { const carriageIndex = line.lastIndexOf('\r') const latestRedraw = carriageIndex >= 0 ? line.slice(carriageIndex + 1) : line if (!latestRedraw.includes('\u0008') && !latestRedraw.includes('\u001b')) { return { text: latestRedraw, cursorColumn: latestRedraw.length, hadControl: carriageIndex >= 0 } } const chars: string[] = [] let cursor = 0 const moveCursorTo = (nextCursor: number): void => { cursor = clampTerminalPreviewCursor(nextCursor) } const writeChar = (char: string): void => { if (cursor > chars.length) { const oldLength = chars.length chars.length = cursor chars.fill(' ', oldLength, cursor) } if (cursor >= chars.length) { chars.push(char) } else { chars[cursor] = char } cursor += 1 } for (let index = 0; index < latestRedraw.length; index += 1) { const char = latestRedraw[index] if (char === '\u0008') { if (cursor > 0) { cursor -= 1 } } else if (char === '\u001b') { const parsed = parseAnsiControlSequence(latestRedraw, index) if (!parsed) { continue } index = parsed.endIndex if (parsed.kind !== 'csi') { continue } if (!hasCanonicalNumericCsiParams(parsed.params)) { continue } if (parsed.final === 'K') { const mode = parsed.firstParam ?? 0 if (mode === 0) { chars.length = cursor } else if (mode === 1) { const deleteCount = Math.min(cursor + 1, chars.length) chars.fill(' ', 0, deleteCount) } else if (mode === 2) { chars.length = 0 } } else if (parsed.final === 'G' || parsed.final === '`') { moveCursorTo((parsed.firstParam ?? 1) - 1) } else if (parsed.final === 'D') { cursor = Math.max(0, cursor - (parsed.firstParam ?? 1)) } else if (parsed.final === 'C') { moveCursorTo(cursor + (parsed.firstParam ?? 1)) } } else { writeChar(char) } } return { text: chars.join(''), cursorColumn: cursor, hadControl: true } } function clampTerminalPreviewCursor(nextCursor: number): number { if (!Number.isFinite(nextCursor)) { return MAX_TAIL_PARTIAL_CHARS } return Math.min(MAX_TAIL_PARTIAL_CHARS, Math.max(0, Math.floor(nextCursor))) } function parseAnsiControlSequence( value: string, escapeIndex: number ): | { kind: 'csi'; final: string; params: string; firstParam: number | null; endIndex: number } | { kind: 'other' endIndex: number } | null { const introducer = value[escapeIndex + 1] if (introducer === '[') { for (let index = escapeIndex + 2; index < value.length; index += 1) { const code = value.charCodeAt(index) if (code < 0x40 || code > 0x7e) { continue } const params = value.slice(escapeIndex + 2, index) const firstParamMatch = /^(\d+)/.exec(params) return { kind: 'csi', final: value[index] ?? '', params, firstParam: firstParamMatch ? Number(firstParamMatch[1]) : null, endIndex: index } } return null } if (introducer === ']') { for (let index = escapeIndex + 2; index < value.length; index += 1) { if (value[index] === '\u0007') { return { kind: 'other', endIndex: index } } if (value[index] === '\u001b' && value[index + 1] === '\\') { return { kind: 'other', endIndex: index + 1 } } } return null } if (isStTerminatedStringControlIntroducer(introducer)) { for (let index = escapeIndex + 2; index < value.length; index += 1) { if (value[index] === '\u001b' && value[index + 1] === '\\') { return { kind: 'other', endIndex: index + 1 } } } return null } return { kind: 'other', endIndex: escapeIndex + 1 } } function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean { return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' } function hasCanonicalNumericCsiParams(params: string): boolean { return /^[0-9;]*$/.test(params) } function containsTerminalVerticalLineControl(value: string): boolean { for (let index = 0; index < value.length; index += 1) { if (value[index] !== '\u001b') { continue } const parsed = parseAnsiControlSequence(value, index) if (!parsed) { return false } index = parsed.endIndex if ( parsed.kind === 'csi' && parsed.final === 'A' && hasCanonicalNumericCsiParams(parsed.params) ) { return true } } return false } function appendCompletedTerminalTranscript( previousLines: string[], previousCharacters: number, newlyCompletedLines: string[], newCompleteLineCount: number ): { lines: string[]; characters: number; truncated: boolean } { if (newCompleteLineCount === 0) { return { lines: previousLines, characters: previousCharacters, truncated: false } } const omittedNewLineCount = Math.max(0, newCompleteLineCount - newlyCompletedLines.length) const lines = omittedNewLineCount > 0 ? [] : [...previousLines] let characters = omittedNewLineCount > 0 ? 0 : previousCharacters for (const line of newlyCompletedLines) { lines.push(line) characters += line.length } let dropCount = Math.max(0, lines.length - MAX_TAIL_LINES) for (let index = 0; index < dropCount; index += 1) { characters -= lines[index]!.length } while (dropCount < lines.length && characters > MAX_TAIL_CHARS) { characters -= lines[dropCount]!.length dropCount += 1 } return { lines: dropCount > 0 ? lines.slice(dropCount) : lines, characters, truncated: omittedNewLineCount > 0 || dropCount > 0 } } function tailStateMatches( lines: string[], transcriptLines: string[], partialLine: string, pendingAnsi: string, redrawCursor: RetainedTailRedrawCursor | null, truncated: boolean, linesTotal: number, snapshot: { lines: string[] transcriptLines: string[] partialLine: string pendingAnsi: string redrawCursor: RetainedTailRedrawCursor | null truncated: boolean linesTotal: number } ): boolean { if ( partialLine !== snapshot.partialLine || pendingAnsi !== snapshot.pendingAnsi || !tailRedrawCursorsMatch(redrawCursor, snapshot.redrawCursor) || truncated !== snapshot.truncated || linesTotal !== snapshot.linesTotal || lines.length !== snapshot.lines.length || transcriptLines.length !== snapshot.transcriptLines.length ) { return false } if (lines === snapshot.lines) { return true } for (let index = 0; index < lines.length; index++) { if (lines[index] !== snapshot.lines[index]) { return false } } if (transcriptLines !== snapshot.transcriptLines) { for (let index = 0; index < transcriptLines.length; index++) { if (transcriptLines[index] !== snapshot.transcriptLines[index]) { return false } } } return true } function tailRedrawCursorsMatch( left: RetainedTailRedrawCursor | null, right: RetainedTailRedrawCursor | null ): boolean { if (left === right) { return true } if (!left || !right) { return false } return left.rowFromEnd === right.rowFromEnd && left.column === right.column } function buildTailLines(lines: string[], partialLine: string): string[] { return partialLine.length > 0 ? [...lines, partialLine] : lines } function terminalReadLimit(limit: number | undefined, defaultLimit: number): number { if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0) { return defaultLimit } return Math.min(Math.max(1, Math.floor(limit)), MAX_TERMINAL_READ_LIMIT) } function trimTerminalPreviewToCharacterBudget( lines: string[], characterBudget: number ): { tail: string[]; limited: boolean; omittedLineCount: number; slicedFirstLine: boolean } { let totalCharacters = lines.reduce((sum, line) => sum + line.length, 0) if (totalCharacters <= characterBudget) { return { tail: lines, limited: false, omittedLineCount: 0, slicedFirstLine: false } } let omittedLineCount = 0 while ( omittedLineCount < lines.length && totalCharacters - lines[omittedLineCount].length >= characterBudget ) { totalCharacters -= lines[omittedLineCount].length omittedLineCount += 1 } const tail = omittedLineCount > 0 ? lines.slice(omittedLineCount) : [...lines] let slicedFirstLine = false if (tail.length > 0 && totalCharacters > characterBudget) { tail[0] = tail[0].slice(totalCharacters - characterBudget) slicedFirstLine = true } return { tail, limited: true, omittedLineCount, slicedFirstLine } } function readTerminalTail(args: { handle: string status: RuntimeTerminalState previewLines: string[] completedLines: string[] partialLine: string completedLineCount: number bufferTruncated: boolean cursor?: number limit?: number }): RuntimeTerminalRead { const oldestCursor = Math.max(0, args.completedLineCount - args.completedLines.length) const latestCursor = args.completedLineCount if (typeof args.cursor === 'number' && args.cursor >= 0) { const limit = terminalReadLimit(args.limit, MAX_TERMINAL_READ_LIMIT) if (args.cursor > latestCursor) { return { handle: args.handle, status: args.status, tail: [], truncated: false, limited: false, oldestCursor: String(oldestCursor), nextCursor: String(latestCursor), latestCursor: String(latestCursor), returnedLineCount: 0 } } // Why: cursor reads return completed lines only, so a partial isn't delivered once as "hel" then again as "hello" after the newline. const startCursor = Math.max(args.cursor, oldestCursor) const startIndex = startCursor - oldestCursor const available = args.completedLines.slice(startIndex) const tail = available.slice(0, limit) const nextCursor = startCursor + tail.length return { handle: args.handle, status: args.status, tail, truncated: args.cursor < oldestCursor, limited: tail.length < available.length, oldestCursor: String(oldestCursor), nextCursor: String(nextCursor), latestCursor: String(latestCursor), returnedLineCount: tail.length } } // Why: un-cursored reads are preview reads — return the latest bounded view; the larger buffer stays available via cursor reads + --limit. const limit = terminalReadLimit(args.limit, DEFAULT_TERMINAL_READ_LIMIT) const allLines = buildTailLines(args.previewLines, args.partialLine) const lineBoundedTail = allLines.slice(-limit) const charBoundedTail = trimTerminalPreviewToCharacterBudget( lineBoundedTail, MAX_TERMINAL_PREVIEW_CHARS ) const lineBoundedStartIndex = Math.max(0, allLines.length - lineBoundedTail.length) const charBoundedStartIndex = lineBoundedStartIndex + charBoundedTail.omittedLineCount const hasPageableOmittedCompletedLines = Math.min(args.completedLineCount, charBoundedStartIndex) > 0 || (charBoundedTail.slicedFirstLine && charBoundedStartIndex < args.completedLineCount) // Why: a long partial line trimmed by the char budget can't be recovered via nextCursor, since cursor reads only page completed lines. const truncatedByNonPageablePartial = charBoundedTail.limited && !hasPageableOmittedCompletedLines return { handle: args.handle, status: args.status, tail: charBoundedTail.tail, truncated: args.bufferTruncated || truncatedByNonPageablePartial, limited: lineBoundedTail.length < allLines.length || charBoundedTail.limited, oldestCursor: String(oldestCursor), nextCursor: String(latestCursor), latestCursor: String(latestCursor), returnedLineCount: charBoundedTail.tail.length } } function shouldFallbackToVisibleTerminalSnapshot( read: RuntimeTerminalRead, opts: { cursor?: number; limit?: number } ): boolean { if (typeof opts.cursor === 'number') { return false } if (read.tail.length === 0) { return false } const hasSubstantialBlankTail = read.limited === true || read.truncated || read.tail.length >= DEFAULT_TERMINAL_READ_LIMIT return hasSubstantialBlankTail && read.tail.every((line) => line.trim().length === 0) } function visibleNonBlankTerminalLines(lines: string[]): string[] { return lines.map((line) => line.trimEnd()).filter((line) => line.trim().length > 0) } function buildVisibleSnapshotReadFallback( read: RuntimeTerminalRead, visibleLines: string[], limit: number | undefined ): RuntimeTerminalRead { const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT) const lineBoundedTail = visibleLines.slice(-lineLimit) const charBoundedTail = trimTerminalPreviewToCharacterBudget( lineBoundedTail, MAX_TERMINAL_PREVIEW_CHARS ) return { ...read, tail: charBoundedTail.tail, limited: read.limited || lineBoundedTail.length < visibleLines.length || charBoundedTail.limited, returnedLineCount: charBoundedTail.tail.length } } function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState { if (leaf.connected) { return 'running' } if (leaf.lastExitCode !== null) { return 'exited' } return 'unknown' } function buildSendPayload(action: { text?: string enter?: boolean interrupt?: boolean }): string | null { let payload = '' if (typeof action.text === 'string' && action.text.length > 0) { payload += action.text } if (action.enter) { payload += '\r' } if (action.interrupt) { payload += '\x03' } return payload.length > 0 ? payload : null } async function assertTerminalInputWithinLimitWithYield(text: string | undefined): Promise { if (!text) { return } if (await isTerminalInputTooLargeWithYield(text)) { throw new Error(TERMINAL_INPUT_TOO_LARGE_ERROR) } } // Why: tui-idle needs OSC title transitions; an unsupported CLI/plain shell never fires one, so cap at 5min to avoid indefinite hangs. const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 const TUI_IDLE_POLL_INTERVAL_MS = 2000 const TUI_IDLE_QUIESCENCE_MS = 3000 const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' const GEMINI_IDLE_PREFIX = '\u25c7' const PI_IDLE_PREFIX = '\u03c0 - ' // Clamp for mobileAutoRestoreFitMs: floor above the legacy 300ms debounce, 1h ceiling (a held PTY beyond that is "I forgot", not intentional). const MOBILE_AUTO_RESTORE_FIT_MIN_MS = 5_000 const MOBILE_AUTO_RESTORE_FIT_MAX_MS = 60 * 60 * 1000 function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { const status = detectAgentStatusFromTitle(title) if (status !== 'idle') { return null } // Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence. if ( EXPLICIT_IDLE_TITLE_RE.test(title) || title.startsWith(CLAUDE_IDLE_PREFIX) || title.startsWith('* ') || title.includes(GEMINI_IDLE_PREFIX) || title.startsWith(PI_IDLE_PREFIX) ) { return 'idle' } return null } function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() const readyIndex = findKnownReadyPromptIndex(normalized) if (readyIndex === null) { return false } const blockedSignal = findTerminalWaitBlockedSignal(normalized) if (blockedSignal !== null && blockedSignal.index > readyIndex) { return false } return true } function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { const normalized = preview.toLowerCase() return findActionableTerminalWaitBlockedSignal(normalized)?.reason ?? null } function findActionableTerminalWaitBlockedSignal( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { const blockedSignal = findTerminalWaitBlockedSignal(normalized) if (blockedSignal === null) { return null } const dismissedModalIndex = findDismissedStartupModalIndex(normalized) // Why: a live prompt after the modal means it was dismissed → signal no longer actionable, even mid-run (Cursor never reports idle via OSC title). return dismissedModalIndex !== null && dismissedModalIndex > blockedSignal.index ? null : blockedSignal } // Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. function findDismissedStartupModalIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), findAntigravityReadyPromptIndex(normalized), findCursorActivePromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } function findKnownReadyPromptIndex(normalized: string): number | null { const indexes = [ findCodexReadyPromptIndex(normalized), findAntigravityReadyPromptIndex(normalized), findCursorReadyPromptIndex(normalized) ].filter((index): index is number => index !== null) return indexes.length > 0 ? Math.max(...indexes) : null } // Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. function findCursorActivePromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('cursor agent') if (headerIndex === -1) { return null } return normalized.includes('→', headerIndex) ? headerIndex : null } // Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ function findCursorReadyPromptIndex(normalized: string): number | null { const activeIndex = findCursorActivePromptIndex(normalized) if (activeIndex === null) { return null } return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex } function findCodexReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('openai codex') if (headerIndex === -1) { return null } const readySegment = normalized.slice(headerIndex) // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null } function findAntigravityReadyPromptIndex(normalized: string): number | null { const headerIndex = normalized.lastIndexOf('antigravity cli') if (headerIndex === -1) { return null } let lineStart = headerIndex let modelIndex: number | null = null let promptIndex: number | null = null // Why: ready previews can include echoed paste after the header; scan line bounds directly instead of splitting the whole tail. for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { continue } let trimmedStart = lineStart let trimmedEnd = cursor while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { trimmedStart += 1 } while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { trimmedEnd -= 1 } if (lineStart > headerIndex && trimmedStart < trimmedEnd) { if (modelIndex === null && normalized.startsWith('gemini', trimmedStart)) { modelIndex = trimmedStart } if ( promptIndex === null && trimmedEnd - trimmedStart === 1 && normalized.charCodeAt(trimmedStart) === 62 ) { promptIndex = trimmedStart } } lineStart = cursor + 1 } return modelIndex !== null && promptIndex !== null ? Math.max(modelIndex, promptIndex) : null } function isTerminalWaitWhitespace(value: string, index: number): boolean { const code = value.charCodeAt(index) return code === 32 || (code >= 9 && code <= 13) } const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust/i function findTerminalWaitBlockedSignal( normalized: string ): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { // Why: one combined negative scan over the up-to-256 KiB tail avoids a dozen full-tail searches when no prompt can match. if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { return null } const candidates: { reason: RuntimeTerminalWaitBlockedReason; index: number }[] = [] const updateIndex = normalized.lastIndexOf('update available') if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) { candidates.push({ reason: 'codex-update-prompt', index: updateIndex }) } const cwdIndex = normalized.lastIndexOf('choose working directory to') if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) { candidates.push({ reason: 'codex-cwd-prompt', index: cwdIndex }) } const modelMigrationIndex = normalized.lastIndexOf('codex just got an upgrade') if ( modelMigrationIndex !== -1 && normalized.includes('press enter to continue', modelMigrationIndex) ) { candidates.push({ reason: 'codex-model-migration-prompt', index: modelMigrationIndex }) } const hooksIndex = normalized.lastIndexOf('hooks need review') if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) { candidates.push({ reason: 'codex-hooks-review-prompt', index: hooksIndex }) } const trustIndex = Math.max( normalized.lastIndexOf('do you trust'), normalized.lastIndexOf('trust this'), normalized.lastIndexOf('trusted workspace') ) const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) if ( trustIndex !== -1 && (trustSegment.includes('workspace') || trustSegment.includes('folder') || trustSegment.includes('directory') || trustSegment.includes('repo')) ) { candidates.push({ reason: 'codex-trust-workspace', index: trustIndex }) } const interactivePromptIndex = Math.max( normalized.lastIndexOf('press enter to confirm'), normalized.lastIndexOf('press enter to continue'), normalized.lastIndexOf('press enter to view'), normalized.lastIndexOf('press enter to insert'), normalized.lastIndexOf('press t to trust') ) const interactivePromptContext = interactivePromptIndex === -1 ? '' : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) const hasCodexInteractiveContext = interactivePromptContext.includes('codex') || interactivePromptContext.includes('permission') || interactivePromptContext.includes('sandbox') || interactivePromptContext.includes('trust') || interactivePromptContext.includes('hook') if (interactivePromptIndex !== -1 && hasCodexInteractiveContext) { const contextStart = Math.max(0, interactivePromptIndex - 600) const hasSpecificPromptInContext = candidates.some( (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex ) if (!hasSpecificPromptInContext) { candidates.push({ reason: 'codex-interactive-prompt', index: interactivePromptIndex }) } } return candidates.length > 0 ? candidates.reduce((latest, candidate) => candidate.index > latest.index ? candidate : latest ) : null } function buildTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, leaf: RuntimeLeafRecord ): RuntimeTerminalWait { return buildTerminalWait(handle, condition, getTerminalState(leaf), leaf.lastExitCode) } function buildTerminalWaitBlockedResult( handle: string, condition: RuntimeTerminalWaitCondition, leaf: RuntimeLeafRecord, blockedReason: RuntimeTerminalWaitBlockedReason ): RuntimeTerminalWait { return buildTerminalWait( handle, condition, getTerminalState(leaf), leaf.lastExitCode, blockedReason ) } function buildPtyTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, pty: RuntimePtyWorktreeRecord ): RuntimeTerminalWait { return buildTerminalWait(handle, condition, getPtyTerminalState(pty), pty.lastExitCode) } function buildPtyTerminalWaitBlockedResult( handle: string, condition: RuntimeTerminalWaitCondition, pty: RuntimePtyWorktreeRecord, blockedReason: RuntimeTerminalWaitBlockedReason ): RuntimeTerminalWait { return buildTerminalWait( handle, condition, getPtyTerminalState(pty), pty.lastExitCode, blockedReason ) } function buildTerminalWait( handle: string, condition: RuntimeTerminalWaitCondition, status: RuntimeTerminalState, exitCode: number | null, blockedReason?: RuntimeTerminalWaitBlockedReason ): RuntimeTerminalWait { return { handle, condition, satisfied: blockedReason === undefined, status, exitCode, ...(blockedReason ? { blockedReason } : {}) } } function getPtyTerminalState(pty: RuntimePtyWorktreeRecord): RuntimeTerminalState { return pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown' } function branchSelectorMatches(branch: string, selector: string): boolean { // Why: Git can report a local branch as `refs/heads/foo` or `foo` depending on the plumbing path; accept either. return normalizeLocalBranchName(branch) === normalizeLocalBranchName(selector) } function runtimePathsEqual(left: string, right: string): boolean { return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) } function runtimeWorktreeIdsEqual(left: string, right: string): boolean { const parsedLeft = splitWorktreeIdForFilesystem(left) const parsedRight = splitWorktreeIdForFilesystem(right) return parsedLeft && parsedRight ? parsedLeft.repoId === parsedRight.repoId && runtimePathsEqual(parsedLeft.worktreePath, parsedRight.worktreePath) : left === right } function runtimeWorktreeIdentityKey(worktreeId: string): string { const parsed = splitWorktreeIdForFilesystem(worktreeId) return parsed ? `${parsed.repoId}\0${normalizeRuntimePathForComparison(parsed.worktreePath)}` : worktreeId } function resolveTerminalSessionWorktreeId( session: WorkspaceSessionState, targetWorktreeId: string ): string | null { const keyedWorktreeIds = new Set([ ...Object.keys(session.tabsByWorktree), ...Object.keys(session.tabGroups ?? {}), ...Object.keys(session.tabGroupLayouts ?? {}), ...Object.keys(session.activeTabIdByWorktree ?? {}), ...Object.keys(session.activeGroupIdByWorktree ?? {}) ]) const matches = [...keyedWorktreeIds].filter((worktreeId) => runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId) ) return matches.length > 1 ? null : (matches[0] ?? targetWorktreeId) } function canonicalizeTerminalSessionWorktreeId( session: WorkspaceSessionState, sourceWorktreeId: string, targetWorktreeId: string ): void { if (sourceWorktreeId === targetWorktreeId) { return } const tabs = session.tabsByWorktree[sourceWorktreeId] ?? [] delete session.tabsByWorktree[sourceWorktreeId] session.tabsByWorktree[targetWorktreeId] = tabs.map((tab) => ({ ...tab, worktreeId: targetWorktreeId })) const groups = session.tabGroups?.[sourceWorktreeId] if (groups) { delete session.tabGroups![sourceWorktreeId] session.tabGroups![targetWorktreeId] = groups.map((group) => ({ ...group, worktreeId: targetWorktreeId })) } for (const keyedState of [ session.tabGroupLayouts, session.activeTabIdByWorktree, session.activeGroupIdByWorktree ]) { if (!keyedState || !Object.hasOwn(keyedState, sourceWorktreeId)) { continue } keyedState[targetWorktreeId] = keyedState[sourceWorktreeId] as never delete keyedState[sourceWorktreeId] } } function inferWorktreeIdFromPtyId(ptyId: string): string | null { return parsePtySessionId(ptyId).worktreeId } function indexPersistedPtyWorktreeBindings( session: WorkspaceSessionState | null | undefined ): ReadonlyMap { const worktreeIdByPtyId = new Map() const ambiguousPtyIds = new Set() const bind = (ptyId: string | null | undefined, worktreeId: string): void => { if (!ptyId || ambiguousPtyIds.has(ptyId)) { return } const existingWorktreeId = worktreeIdByPtyId.get(ptyId) if (existingWorktreeId && existingWorktreeId !== worktreeId) { // Why: a corrupt/stale duplicate binding must not attribute a live PTY to whichever workspace was visited first. worktreeIdByPtyId.delete(ptyId) ambiguousPtyIds.add(ptyId) return } worktreeIdByPtyId.set(ptyId, worktreeId) } for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { for (const tab of tabs) { bind(tab.ptyId, worktreeId) bind(session?.remoteSessionIdsByTabId?.[tab.id], worktreeId) const layout = session?.terminalLayoutsByTabId[tab.id] for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { bind(ptyId, worktreeId) } } } return worktreeIdByPtyId } function indexPersistedPtySurfaceBindings( session: WorkspaceSessionState | null | undefined ): ReadonlyMap< string, { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } > { const bindingByPtyId = new Map< string, { worktreeId: string; tabId: string; paneKey: string; incarnationId: string } >() const ambiguousPtyIds = new Set() for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) { for (const tab of tabs) { for (const [leafId, ptyId] of Object.entries( session?.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} )) { if (!ptyId || ambiguousPtyIds.has(ptyId)) { continue } const paneKey = makePaneKey(tab.id, leafId) const incarnationId = session?.terminalPtyIncarnationsByPaneKey?.[paneKey] if (!incarnationId) { continue } const binding = { worktreeId, tabId: tab.id, paneKey, incarnationId } const existing = bindingByPtyId.get(ptyId) if ( existing && (existing.worktreeId !== worktreeId || existing.paneKey !== paneKey || existing.incarnationId !== incarnationId) ) { bindingByPtyId.delete(ptyId) ambiguousPtyIds.add(ptyId) continue } bindingByPtyId.set(ptyId, binding) } } } return bindingByPtyId } function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) { return false } for (const value of a) { if (!b.has(value)) { return false } } return true } function parseRuntimeWorktreeId( worktreeId: string ): { repoId: string; worktreePath: string } | null { const parsed = splitWorktreeId(worktreeId) if (!parsed?.repoId) { return null } if (!parsed.worktreePath) { return null } return parsed } type RuntimeWorktreeSummaryPathCandidate = { summary: RuntimeWorktreePsSummary order: number } type RuntimeWorktreeSummaryPathIndex = { platformByRepoId: ReadonlyMap posixAbsolute: Map posixRelative: Map windows: Map windowsAbsolute: Map } function buildRuntimeWorktreeSummaryPathIndex( summaries: ReadonlyMap, resolvedWorktrees: readonly ResolvedWorktree[], platformByRepoId: ReadonlyMap ): RuntimeWorktreeSummaryPathIndex { const index: RuntimeWorktreeSummaryPathIndex = { platformByRepoId, posixAbsolute: new Map(), posixRelative: new Map(), windows: new Map(), windowsAbsolute: new Map() } for (const [order, worktree] of resolvedWorktrees.entries()) { const summary = summaries.get(worktree.id) if (!summary) { continue } const platform = platformByRepoId.get(worktree.repoId) ?? process.platform const candidate = { summary, order } if (isPosixAbsoluteRuntimeWorktreePath(worktree.path)) { setFirstRuntimeWorktreePathCandidate( index.posixAbsolute, runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), candidate ) continue } const windowsKey = runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, 'win32') setFirstRuntimeWorktreePathCandidate(index.windows, windowsKey, candidate) if (isWindowsAbsolutePathLike(worktree.path)) { setFirstRuntimeWorktreePathCandidate(index.windowsAbsolute, windowsKey, candidate) } else if (platform !== 'win32') { setFirstRuntimeWorktreePathCandidate( index.posixRelative, runtimeWorktreeSummaryPathKey(worktree.repoId, worktree.path, platform), candidate ) } } return index } function findRuntimeWorktreeSummaryByPath( index: RuntimeWorktreeSummaryPathIndex, repoId: string, worktreePath: string, platform: NodeJS.Platform ): RuntimeWorktreePsSummary | null { if (isPosixAbsoluteRuntimeWorktreePath(worktreePath)) { return ( index.posixAbsolute.get(runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform)) ?.summary ?? null ) } const windowsKey = runtimeWorktreeSummaryPathKey(repoId, worktreePath, 'win32') if (platform === 'win32' || isWindowsAbsolutePathLike(worktreePath)) { return index.windows.get(windowsKey)?.summary ?? null } const posixCandidate = index.posixRelative.get( runtimeWorktreeSummaryPathKey(repoId, worktreePath, platform) ) const windowsCandidate = index.windowsAbsolute.get(windowsKey) // Why: a malformed path can match both the POSIX and Windows indexes; keep the old pairwise scan's first-match order. if (!posixCandidate) { return windowsCandidate?.summary ?? null } if (!windowsCandidate || posixCandidate.order < windowsCandidate.order) { return posixCandidate.summary } return windowsCandidate.summary } function setFirstRuntimeWorktreePathCandidate( candidates: Map, key: string, candidate: RuntimeWorktreeSummaryPathCandidate ): void { if (!candidates.has(key)) { candidates.set(key, candidate) } } function isPosixAbsoluteRuntimeWorktreePath(worktreePath: string): boolean { return worktreePath.startsWith('/') && !worktreePath.startsWith('//') } function runtimeWorktreeSummaryPathKey( repoId: string, worktreePath: string, platform: NodeJS.Platform ): string { return `${repoId}\0${worktreePathComparisonKey(worktreePath, platform)}` } function includeTargetResolvedWorktree( resolvedWorktrees: ResolvedWorktree[], targetWorktree: ResolvedWorktree | null ): ResolvedWorktree[] { if (!targetWorktree || resolvedWorktrees.some((worktree) => worktree.id === targetWorktree.id)) { return resolvedWorktrees } return [...resolvedWorktrees, targetWorktree] } function findResolvedWorktreeIdForPath( resolvedWorktrees: ResolvedWorktree[], cwd: string ): string | null { if (!cwd) { return null } const matches = resolvedWorktrees .filter((worktree) => isPathInsideOrEqual(worktree.path, cwd)) .sort((left, right) => right.path.length - left.path.length) return matches[0]?.id ?? null } function getLeafWorktreeStatus( leaf: RuntimeLeafRecord, tabTitle: string | null ): RuntimeWorktreeStatus { // Why: recompute from the live title each call (no sticky state) so worktree.ps mirrors the desktop sidebar's getWorktreeStatus. const titleCandidates = [ { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, { title: tabTitle, updatedAt: 0 } ] const latestTitle = getLatestAgentCandidateTitle(...titleCandidates) const detected = latestTitle ? detectAgentStatusFromTitle(latestTitle) : leaf.lastAgentStatus return getDetectedWorktreeStatus(detected, leaf.ptyId !== null) } function classifyLatestAgentTitle( ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] ): 'agent' | 'management' | 'neutral' { return classifyAgentTitle(getLatestAgentCandidateTitle(...titles)) } function getLatestPtyTitle(pty: RuntimePtyWorktreeRecord): string | null { return getLatestAgentCandidateTitle( { title: pty.title, updatedAt: pty.titleUpdatedAt }, { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) } function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): string | null { return getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, { title: tabTitle, updatedAt: 0 } ) } function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { if (!title) { return 'neutral' } if (isClaudeManagementTitle(title)) { return 'management' } return detectAgentStatusFromTitle(title) !== null ? 'agent' : 'neutral' } function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean { if (!title) { return false } return isClaudeManagementTitle(title) || isShellProcess(title) } function getLatestAgentCandidateTitle( ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] ): string | null { return getLatestAgentCandidateTitleInfo(...titles)?.title ?? null } function getLatestAgentCandidateTitleInfo( ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] ): { title: string; updatedAt: number } | null { let latest: { title: string; updatedAt: number } | null = null for (const candidate of titles) { const title = candidate.title?.trim() if (!title) { continue } const updatedAt = candidate.updatedAt ?? 0 if (!latest || updatedAt > latest.updatedAt) { latest = { title, updatedAt } } } return latest } function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { return getDetectedWorktreeStatus(detectAgentStatusFromTitle(title), hasPty) } function getDetectedWorktreeStatus( detected: AgentStatus | null, hasPty: boolean ): RuntimeWorktreeStatus { if (detected === 'permission') { return 'permission' } if (detected === 'working') { return 'working' } return hasPty ? 'active' : 'inactive' } function mapExplicitAgentStateToRuntimeTerminalStatus( state: AgentStatusEntry['state'] ): NonNullable { switch (state) { case 'blocked': case 'waiting': return 'permission' case 'working': return 'working' case 'done': return 'idle' } } function mergeWorktreeStatus( current: RuntimeWorktreeStatus, next: RuntimeWorktreeStatus ): RuntimeWorktreeStatus { return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current } function normalizeTerminalChunk( chunk: string, pendingAnsi: string = '' ): { text: string; pendingAnsi: string } { // Why: skip full ANSI/OSC scanning for the common plain-text PTY chunk (perf on high-throughput streams). if (pendingAnsi.length === 0 && !terminalChunkNeedsNormalization(chunk)) { return { text: chunk, pendingAnsi: '' } } const combined = `${pendingAnsi}${chunk}` const parts: string[] = [] let textStart = 0 for (let index = 0; index < combined.length; index += 1) { const char = combined[index] if (char === '\x1b') { appendTerminalNormalizedSpan(parts, combined, textStart, index) if (index + 1 >= combined.length) { return { text: parts.join(''), pendingAnsi: combined.slice(index) } } const parsed = parseAnsiControlSequence(combined, index) if (!parsed) { return { text: parts.join(''), pendingAnsi: trimPendingAnsiControl(combined.slice(index)) } } if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) { // Why: Codex redraws status text with ANSI controls but no CR; keep them so the tail overwrites the prior frame. parts.push(combined.slice(index, parsed.endIndex + 1)) } index = parsed.endIndex textStart = index + 1 continue } if (char === '\r' && combined[index + 1] === '\n') { appendTerminalNormalizedSpan(parts, combined, textStart, index) parts.push('\n') index += 1 textStart = index + 1 continue } const code = combined.charCodeAt(index) if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) { appendTerminalNormalizedSpan(parts, combined, textStart, index) parts.push(char) textStart = index + 1 } else if (!isTerminalPreviewPrintableCodeUnit(code)) { appendTerminalNormalizedSpan(parts, combined, textStart, index) textStart = index + 1 } } appendTerminalNormalizedSpan(parts, combined, textStart, combined.length) return { text: parts.join(''), pendingAnsi: '' } } function appendTerminalNormalizedSpan( parts: string[], value: string, start: number, end: number ): void { if (end > start) { parts.push(value.slice(start, end)) } } function isTerminalPreviewPrintableCodeUnit(code: number): boolean { return code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f) } function terminalChunkNeedsNormalization(chunk: string): boolean { for (let index = 0; index < chunk.length; index++) { const code = chunk.charCodeAt(index) if ( code === 0x1b || code === 0x7f || code === 0x0d || code < 0x09 || (code > 0x0a && code < 0x20) || (code >= 0x80 && code <= 0x9f) ) { return true } } return false } function trimPendingAnsiControl(value: string): string { if (value.length <= MAX_TAIL_PENDING_ANSI_CHARS) { return value } const introducer = value.slice(0, Math.min(2, value.length)) const suffixBudget = Math.max(0, MAX_TAIL_PENDING_ANSI_CHARS - introducer.length) return `${introducer}${value.slice(-suffixBudget)}` } function isTerminalPreviewLineControl(parsed: { final: string params: string firstParam: number | null }): boolean { if (!hasCanonicalNumericCsiParams(parsed.params)) { return false } if (parsed.final === 'K') { const mode = parsed.firstParam ?? 0 return mode === 0 || mode === 1 || mode === 2 } return ( parsed.final === 'A' || parsed.final === 'G' || parsed.final === '`' || parsed.final === 'D' || parsed.final === 'C' ) } function maxTimestamp(left: number | null, right: number | null): number | null { if (left === null) { return right } if (right === null) { return left } return Math.max(left, right) } function compareWorktreePs( left: RuntimeWorktreePsSummary, right: RuntimeWorktreePsSummary ): number { // Pinned and unread worktrees sort above others so they survive truncation. if (left.isPinned !== right.isPinned) { return left.isPinned ? -1 : 1 } if (left.unread !== right.unread) { return left.unread ? -1 : 1 } // Why: worktree.ps is truncated for mobile, so host-visible activity must sort above inactive rows. if (left.hasHostSidebarActivity !== right.hasHostSidebarActivity) { return left.hasHostSidebarActivity ? -1 : 1 } const leftLast = left.lastOutputAt ?? -1 const rightLast = right.lastOutputAt ?? -1 if (leftLast !== rightLast) { return rightLast - leftLast } if (left.liveTerminalCount !== right.liveTerminalCount) { return right.liveTerminalCount - left.liveTerminalCount } return left.path.localeCompare(right.path) }