/* 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 { 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 { 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, type AgentStatusIpcPayload, type ParsedAgentStatusPayload, type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' 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 } 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 { formatMessagesForInjection } from './orchestration/formatter' import type { Automation, AutomationCreateInput, AutomationRun, AutomationUpdateInput, AutomationWorkspaceMode } from '../../shared/automations-types' import type { AutomationWorkspaceProvenance, BaseRefSearchResult, CreateWorktreeResult, DetectedWorktree, DetectedWorktreeListResult, ForceDeleteWorktreeBranchResult, GitHubPrStartPoint, GitPushTarget, 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, DirEntry } from '../../shared/types' 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 { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import { navigationTargetsClients, navigationTargetsHost, type RuntimeNavigationTarget } from '../../shared/runtime-navigation' 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 } 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 { 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, parsePaneKey } from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' 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 { 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 { isWindowsAbsolutePathLike, isPathInsideOrEqual, normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' 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, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, 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 { RuntimeGraphStatus, RuntimeRepoSearchRefs, RuntimeTerminalRead, RuntimeTerminalRename, RuntimeTerminalAgentStatus, RuntimeTerminalSend, RuntimeTerminalCreate, RuntimeTerminalPresentation, RuntimeTerminalSplit, RuntimeTerminalFocus, RuntimeTerminalClose, RuntimeTerminalListResult, RuntimeWorktreeTerminalSleepResult, RuntimeTerminalResolvePane, RuntimeTerminalState, RuntimeStatus, RuntimeSyncWindowGraphResult, RuntimeTerminalWait, RuntimeTerminalWaitBlockedReason, RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, RuntimeWorktreeAgentRow, RuntimeWorktreeStatus, RuntimeSpeechModelSummary, RuntimeSpeechSetupState, RuntimeTerminalShow, RuntimeTerminalSummary, RuntimeTerminalVisualGroupNode, RuntimeTerminalVisualLayout, RuntimeTerminalVisualLayoutNode, RuntimeTerminalVisualPaneNode, RuntimeTerminalVisualTab, RuntimeSyncedLeaf, RuntimeSyncedTab, RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult, RuntimeMobileSessionCreateTerminalResult, RuntimeMobileSessionClientTab, RuntimeMobileSessionTabCloseResult, RuntimeMobileSessionMarkdownTab, RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabGroup, RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot, RuntimeSessionTabCloseReason, RuntimeBrowserDriverState, RuntimeTerminalDriverState, RuntimeSyncWindowGraph, RuntimeWorktreeListResult, BrowserTabInfo, BrowserScreencastResult } from '../../shared/runtime-types' import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' 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 { 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, setEmulatorBridge } from './orca-runtime-emulator' import type { EmulatorBridge } from '../emulator/emulator-bridge' import { RuntimeFileCommands } from './orca-runtime-files' import { RuntimeGitCommands } from './orca-runtime-git' import { activateClientSessionTabSelection, ClientSessionTabSelectionStore, deriveClientSessionTabSelection, projectClientSessionTabSelection } from './client-session-tab-selection' import type { PtyProviderBufferSnapshot } 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 { BrowserWindow, ipcMain } from 'electron' 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 } from '../github/client' import type { GitHubPRBranchLookupOptions } from '../github/client' import { resolveGitHubPrStartPoint } from '../github/pr-start-point' import { fetchPrHeadTrackingRef } from '../github/pr-head-tracking-ref' 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 type { GitHubIssueUpdate, GitHubPullRequestStateUpdate, GitHubPRFile, GitHubPRReviewCommentInput, GitLabIssueUpdate, GitLabMRInlineCommentInput, GitLabProjectRef, GitLabWorkItem, ListWorkItemsResult, MRListState, PRRefreshOutcome } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { CreateHostedReviewInput, 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, 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 } from '../ipc/filesystem-auth' import { createSetupRunnerScript, getDefaultTabCommandTrustContent, getDefaultTabsLaunch, getEffectiveHooks, getEffectiveSetupRunPolicy, hasUnrecognizedOrcaYamlKeys, hasHooksFile, loadHooks, parseOrcaYaml, readIssueCommand, runHook, shouldRunSetupForCreate, writeIssueCommand } from '../hooks' import { DEFAULT_REPO_BADGE_COLOR, FLOATING_TERMINAL_WORKTREE_ID, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeLinkedPaths, findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from '../ipc/worktree-symlinks' import { deleteWorktreeHistoryDir } from '../terminal-history' 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 { computeBranchName, computeWorktreePath, computeWorkspaceRoot, ensurePathWithinWorkspace, formatWorktreeRemovalError, getWorktreeCreationLayout, getWorktreePathSettings, isOrphanCompatiblePreflightError, isOrphanedWorktreeError, mergeWorktree, sanitizeWorktreeName, shouldSetDisplayName, areWorktreePathsEqual } from '../ipc/worktree-logic' 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 { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' 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 { 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 { 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 type { IFilesystemProvider, IPtyProvider, PtyProcessInfo, PtyTransientFact } from '../providers/types' 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 } from '../codex-accounts/service' import type { RateLimitService } from '../rate-limits/service' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides' import type { RateLimitState } from '../../shared/rate-limit-types' 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 } 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'] 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'] 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 // 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 managementTitle: string | null managementTitleAt: number | 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 terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string focus?: boolean rendererBacked?: boolean activate?: boolean presentation?: RuntimeTerminalPresentation tabId?: string leafId?: string sessionId?: string 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 } } } 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 byte order (titles in // sequence, then bell). 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 payload: ParsedAgentStatusPayload // When the current payload.state was first observed for this pane (ms). stateStartedAt: number updatedAt: number } 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 } type RuntimeVisibleTerminalState = { lines: string[] isAlternateScreen: boolean sequence: number generation: number } type ProviderBufferAcquisition = { generation: number scrollbackRows: number promise: Promise } 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 = { 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 persistHostSessionBinding?: boolean terminalColorQueryReplies?: { foreground?: string; background?: string } agentSessionEnsure?: { claim: AgentSessionExecutionClaim surface: AgentSessionSurfaceBinding } agentSessionCreateOperationId?: string signal?: AbortSignal onPtySpawnCommitted?: () => void }): Promise<{ id: string incarnationId?: PtyIncarnationId wslDistro?: string agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean 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 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?(): 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 } 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 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(' ') } 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 tabId?: string leafId?: string splitFromLeafId?: string splitDirection?: 'horizontal' | 'vertical' splitTelemetrySource?: TerminalPaneSplitSource } ): | Promise<{ tabId: string; title?: string | null }> | { tabId: string; title?: string | null } | 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 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 } 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: void) => void timeout: NodeJS.Timeout | null abortCleanup: (() => void) | null } 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): string { return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}` } 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 } 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, 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.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) { return computeBranchName(sanitizedName, settings, 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' title: string body: string worktreeId?: string notificationId?: string notificationSeq?: number } export type MobileNotificationDismissEvent = { type: 'dismiss' notificationId: string notificationSeq?: number } 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)[] }[] } 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 ) } export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() private readonly store: RuntimeStore | null private rendererGraphEpoch = 0 private graphStatus: RuntimeGraphStatus = 'unavailable' private authoritativeWindowId: number | null = null 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 } >() 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 selectIfNoActiveTab: boolean viewMode?: 'terminal' | 'chat' } >() 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) ) 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 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>() 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?: { seq?: number; rawLength?: number; transformed?: boolean; cwd?: string } ) => void > >() // 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() // 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 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: 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 terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null 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 sshRelayRecoveryGenerationByTargetId = 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[] // 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 } ) { 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) }) if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null // 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, connectionId?: string ): Promise { 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, localProvider: provider, onPtyStopped: this.onPtyStopped ?? undefined, requirePhysicalStop: 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 ?? [] } } updateClientSettings( updates: Pick< Partial, | 'agentStatusHooksEnabled' | 'defaultTuiAgent' | 'disabledTuiAgents' | 'agentDefaultArgs' | 'agentDefaultEnv' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' | 'defaultRepoSelection' | 'defaultLinearTeamSelection' | 'githubProjects' | 'experimentalNewWorktreeCardStyle' | 'compactWorktreeCards' | 'minimaxGroupId' | 'minimaxUsageModels' | 'prBotAuthorOverrides' > ): 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 before = this.store.getSettings().agentStatusHooksEnabled !== false this.store.updateSettings(updates, { notifyListeners: true }) if ( typeof updates.agentStatusHooksEnabled === 'boolean' && before !== updates.agentStatusHooksEnabled ) { applyAgentStatusHooksEnabled(updates.agentStatusHooksEnabled) } 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 } setAutomationService(service: AutomationService): void { this.automationService = service } getRuntimeId(): string { return this.runtimeId } getStartedAt(): number { return this.startedAt } private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId { const repo = this.store?.getRepo?.(getRepoIdFromWorktreeId(worktreeId)) return repo ? getRepoExecutionHostId(repo) : 'local' } private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null { return ( this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) ?? 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 } 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) ) 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): () => void { this.clientEventListeners.add(listener) return () => { this.clientEventListeners.delete(listener) } } 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 } private emitClientEvent(event: RuntimeClientEvent): void { for (const listener of this.clientEventListeners) { listener(event) } } private notifyWorktreesChanged(repoId: string): void { this.notifier?.worktreesChanged(repoId) this.emitClientEvent({ type: 'worktreesChanged', repoId }) } 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) this.emitClientEvent({ type: 'sshStateChanged', targetId, state: getPublicSshState(state)! }) } notifySshRelayReady(targetId: string): void { const generation = this.bumpSshRelayRecoveryGeneration(targetId) void this.publishRecoveredSshMobileSessionTabs(targetId, generation).catch((error) => { if (this.sshRelayRecoveryGenerationByTargetId.get(targetId) !== generation) { return } console.warn('[runtime] failed to publish recovered SSH session tabs', { 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 }) } 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 setEmulatorBridge(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 changedMobileWorktrees = this.syncMobileSessionTabs(graph.mobileSessionTabs) 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() return { ...this.getStatus(), ...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}) } } // 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) 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() 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 } = {} ): 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 = 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 && !this.workspaceSessionHasRuntimeOwnedPtyCandidate(session) ) { return reconciledWorktreeIds } const entries = worktreeId !== undefined ? ([[worktreeId, session.tabsByWorktree[worktreeId] ?? []]] as const) : Object.entries(session.tabsByWorktree ?? {}) for (const [entryWorktreeId, persistedTabs] of entries) { 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 ).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 { for (const tabs of Object.values(session.tabsByWorktree ?? {})) { for (const tab of tabs) { if (this.isServeOrSshOwnedPtyId(tab.ptyId)) { return true } const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId if ( leafPtyIds && Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId)) ) { return true } } } // Why: expiry clears the stale PTY id but retains pane coordinates so paired viewers can ask the HUB for a fresh shell. return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) => tabs.some((tab) => 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)) } // 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' } } ): 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) 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.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) } else { // 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) } } } 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 } 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 } let publishableRetiredSurfaces = retiredSurfaces const session = this.store?.getWorkspaceSession?.() if (session) { // 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 } let nextSession = session const acceptedSurfaces: RetiredTerminalSurface[] = [] for (const surface of retiredSurfaces) { const candidate = retireTerminalSurfaceFromPersistence(nextSession, surface) if (candidate !== nextSession) { acceptedSurfaces.push(surface) nextSession = candidate } } if (acceptedSurfaces.length === 0) { return } try { this.store.setWorkspaceSession(nextSession) this.store.flushOrThrow() } catch (error) { console.error('[runtime] failed to persist terminal retirement:', error) return } // Why: one repo epoch can cover multiple exits, but only surfaces individually accepted by persistence may disappear. publishableRetiredSurfaces = acceptedSurfaces } else { for (const surface of retiredSurfaces) { const repoId = getRepoIdFromWorktreeId(surface.worktreeId) this.terminalTopologyRevisionByRepoId.set( repoId, (this.terminalTopologyRevisionByRepoId.get(repoId) ?? 0) + 1 ) } } 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[] ): RuntimeMobileSessionTerminalTab[] { const session = this.getWorkspaceSessionForWorktree(worktreeId) if (!session) { return [] } 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(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( worktreeId: string, tabId: string, leafId: string, layout: TerminalLayoutSnapshot | undefined ): boolean { const session = this.getWorkspaceSessionForWorktree(worktreeId) 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): 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) { 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 } = {} ): 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' && (!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.createHeadlessMobileSessionTerminal(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 } 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 } = {} ): Promise { const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) const observedPtyIds = await this.refreshMobileSessionPtyRecords() 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. await this.notifier.closeTerminalTab(tab.parentTabId) 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) this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() } 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) } } 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: { killPtys?: boolean } = {} ): void { const closedParentTabId = tab.parentTabId const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId) // 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), 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 ): Promise => { const results = await Promise.allSettled([ connectionId ? closeRemoteWatcherForWorktreePath(connectionId, worktreePath) : closeLocalWatcherForWorktreePath(worktreePath), this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId) ]) 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) 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) await gate.ready await this.closeFileWatchersForRemoval(worktreePath, connectionId) 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 }) 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 } } onMobileSessionTabsChanged( listener: (snapshot: RuntimeMobileSessionTabsResult) => void, clientNavigationId?: string ): () => void { 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): void { const trimmed = handle?.trim() if (!trimmed || !trimmed.startsWith('term_')) { return } if (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) } // 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) } 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) // 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, ...(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) } } /** * Handles incoming data from a PTY process, running agent detection, * updating terminal tail buffers, and triggering foreground agent refreshes. */ onPtyData( ptyId: string, data: string, at: number, sequenceChars = data.length, transformed = false ): 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). this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies) 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 { 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 } : {}) } for (const listener of listeners) { listener(data, meta) } } 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 = '' } 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): 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: '' }) } } /** 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.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' }) } } /** 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.onTerminalSideEffects || !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.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable || facts.length === 0 ) { return } const batch: TerminalSideEffectBatch = { ptyId, seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, facts, ...(options.replay ? { replay: true } : {}), ...this.resolveTerminalSideEffectAttribution(ptyId) } try { this.onTerminalSideEffects(batch) } catch (err) { console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) } } /** 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 makeMobileTitleGateKey(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.makeMobileTitleGateKey(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' }) }, // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the // per-chunk bell walk and 133/URL/2031 scans entirely. ...(this.terminalSideEffectConsumerAvailable ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) }, onCommandFinished: (exitCode: number | null) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, onPrLink: (link: TerminalGitHubPRLink) => { this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) }, // Why: hidden-delivery-gated views never see the bytes, so main // surfaces DECSET 2031 subscribes as facts; the theme reply is // still sent by the renderer (query authority stays with the view). onMode2031Subscribe: () => { this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) } } : {}) }, initialTitle !== null ? { initialTitle } : {} ) 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 ? 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 }) } }) : 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.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) } 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.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 { const nextAvailable = available && this.onTerminalSideEffects !== null if (nextAvailable === this.terminalSideEffectConsumerAvailable) { return } this.terminalSideEffectConsumerAvailable = nextAvailable // Why: optional bell/command/link scanners are selected when a tracker is // created. Rebuild at the window boundary so pure headless output stays cheap. for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { this.disposePtyTitleTracker(ptyId) } } 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, 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, 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, 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++) // 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?: { seq?: number; rawLength?: number; transformed?: boolean; cwd?: string } ) => void ): () => void { return addListenerToMap(this.dataListeners, ptyId, listener) } /** 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.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) } } /** 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 } for (const listener of listeners) { listener({ mode, cols, rows }) } } serializeTerminalBuffer( 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 pendingEscapeTailAnsi?: string } | null> { 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: 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 ): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = 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 }) .catch(() => { // Best-effort state tracking; live streaming must continue even if // xterm rejects a malformed or raced write during shutdown. }) } /** 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 } = {} ): 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 } this.providerBufferAcquisitionsByPtyId.set(ptyId, acquisition) void promise.finally(() => { if (this.providerBufferAcquisitionsByPtyId.get(ptyId) === acquisition) { this.providerBufferAcquisitionsByPtyId.delete(ptyId) } }) } return typeof wait.timeoutMs === 'number' ? withTimeout(acquisition.promise, wait.timeoutMs, null) : acquisition.promise } 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 knownAlternateScreen = this.isTerminalAlternateScreen(ptyId) const providerModeUnknown = this.providerSnapshotPreferredPtys.has(ptyId) && !this.providerModeTrackersByPtyId.has(ptyId) if ( !blankFallback && !providerModeUnknown && !knownAlternateScreen && !this.headlessTerminals.has(ptyId) ) { return read } const visibleState = await this.readVisibleTerminalState(ptyId) if (!blankFallback && !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 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 } } 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) for (const listener of this.notificationListeners) { // 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 duplicate local pushes). listener({ ...event, notificationSeq: seq }) } } // 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): ReplayableMobileNotification[] { return this.mobileNotificationReplay.getMissedSince(lastSeenSeq) } dismissMobileNotification(notificationId: string): void { this.dispatchMobileNotification({ type: 'dismiss', notificationId }) } // ─── 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) } removeClaudeAccount(accountId: string): Promise { return this.requireAccountServices().claudeAccounts.removeAccount(accountId) } removeCodexAccount(accountId: string): Promise { return this.requireAccountServices().codexAccounts.removeAccount(accountId) } // 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 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.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) } 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.disconnectedAt = Date.now() pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) } const preservesAbnormalSshSurface = this.isSshOwnedPtyId(ptyId) && pty?.connectionId != null && exitCode < 0 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) 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) { for (const listener of listeners) { listener(next) } } } // 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 } for (const listener of listeners) { listener(event) } } // 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: { requireFreshPtyLiveness?: 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 refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController( resolvedWorktrees, targetWorktreeId ) if (opts.requireFreshPtyLiveness && !refreshedPtyLiveness) { throw new Error('terminal_liveness_unavailable') } 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)) } } // 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 listedTerminals = terminals.slice(0, limit) const visualLayouts = this.buildTerminalVisualLayouts( listedTerminals, worktreesById, targetWorktreeId ) return { terminals: listedTerminals, ...(visualLayouts.length > 0 ? { visualLayouts } : {}), totalCount: terminals.length, truncated: terminals.length > limit } } 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 } }) .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) 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) } 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, ...(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 } 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) 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) 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 ): void => { if (!state) { 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) } 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 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) } } this.attachAgentRowsToSummaries( summaries, runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId ) 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 ): 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 tabId?: string worktreeId?: string state: ParsedAgentStatusPayload['state'] agentType: string | null prompt: string lastAssistantMessage: string | null toolName: string | null toolInput: string | null interrupted: boolean stateStartedAt: number updatedAt: number } >() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { const { payload } = snapshot rowSources.set(snapshot.paneKey, { paneKey: snapshot.paneKey, tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, 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, tabId: entry.tabId, worktreeId: entry.worktreeId, 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 }) } 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. const tabId = src.tabId ?? parsePaneKey(src.paneKey)?.tabId const worktreeId = (tabId ? mirroredWorktreeIdByTabId.get(tabId) : undefined) ?? 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 } // 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') } let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId) let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { const existingProject = this.listProjects().find((project) => project.id === args.projectId) if ( !existingProject?.providerIdentity || existingProject.providerIdentity.provider !== 'github' ) { throw new Error('Imported folder does not match the selected project identity.') } const updated = this.store.updateRepo(repo.id, { upstream: { owner: existingProject.providerIdentity.owner, repo: existingProject.providerIdentity.repo, ...(existingProject.providerIdentity.host ? { host: existingProject.providerIdentity.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 } } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return await this.setupProjectExistingFolder({ projectId: args.projectId, hostId: args.hostId, path: repo.path, kind: 'git', displayName: args.displayName, setupMethod: 'cloned' }) } 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'] 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' | '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[] }> { 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 } } 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) 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 }> { const leaf = this.resolveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { return { foregroundProcess: null, hasChildProcesses: false } } const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId) const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId).catch(() => false)) ?? 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) return getWorkItem( repo.path, number, type, repo.connectionId ?? null, ...this.getLocalGitExecutionOptionArgs(repo) ) } 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) ) } 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 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, 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 { hasHooks: false, hooks: null, mayNeedUpdate: false } } if (repo.connectionId) { const fsProvider = getSshFilesystemProvider(repo.connectionId) if (!fsProvider) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } try { const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) if (result.isBinary) { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } return { hasHooks: true, hooks: parseOrcaYaml(result.content), mayNeedUpdate: false } } catch { return { hasHooks: false, hooks: null, mayNeedUpdate: false } } } const has = hasHooksFile(repo.path) const hooks = has ? loadHooks(repo.path) : null return { 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): Promise { const repo = await this.resolveRepoSelector(repoSelector) 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?.() ?? {}) } } 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 ): 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 } : {}) }) 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' // 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 }): Promise<{ setupSpawned: boolean }> { if (!this.ptyController?.spawn) { return { setupSpawned: false } } let setupSpawned = false try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, args.worktreeId, args.defaultTabs ) 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) primaryTerminalHandle = terminal.handle } if (args.setup) { const setupCommand = args.wrappedSetupCommand ?? buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) const shouldSplitSetup = primaryTerminalHandle && (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') await (shouldSplitSetup ? this.splitTerminal(primaryTerminalHandle!, { direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', command: setupCommand, env: args.setup.envVars, activate: false }) : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, env: args.setup.envVars })) setupSpawned = true } } 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 } } 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 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' createdWithAgent?: TuiAgent startupAgent?: TuiAgent startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance 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.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 } : {}), ...(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) 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 } : {}), startupCommandDelivery: effectiveStartup.startupCommandDelivery, telemetry: effectiveStartup.telemetry }) 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}`) } 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) 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) const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath)) 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 } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), ...(args.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent ? { pendingFirstAgentMessageRename: true } : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), ...(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) if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths) } let setup: CreateWorktreeResult['setup'] let warning: string | undefined // 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) if (shouldRunSetup && hooks?.scripts.setup) { const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup) 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. setup = createSetupRunnerScript( repo, worktreePath, hooks.scripts.setup, this.getLocalGitExecutionOptionArgs(repo)[0] ) } 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 { 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. warning = `orca.yaml setup hook skipped for ${worktreePath}; pass --setup run to run it.` console.warn(`[hooks] ${warning}`) } 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 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 = getSetupRunnerCommandPlatformForPath( setup.runnerScriptPath, process.platform === 'win32' ? 'windows' : 'posix' ) const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: setup.runnerScriptPath, startupCommand: effectiveStartup.command, platform }) 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 } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry }) 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: setup ? isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix' : 'posix', // 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 } // 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. void this.provisionManagedWorktreeTerminals({ worktreeSelector: `id:${worktree.id}`, worktreeId: worktree.id, worktreePath, ...(setup ? { setup } : {}), ...(defaultTabs ? { defaultTabs } : {}), primaryTerminalHandle: startupTerminalHandle, hasStartupTerminal: didSpawnStartup, setupCommandPlatform: setup ? isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix' : 'posix', ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) // 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 (setup) { didSpawnSetup = true } } else if (this.ptyController?.spawn) { try { await this.createTerminal(`id:${worktree.id}`) } 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 return { worktree: { ...worktree, parentWorktreeId: lineage?.parentWorktreeId ?? null, childWorktreeIds: [], lineage, workspaceLineage, git: created }, ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), ...(returnedSetup ? { setup: returnedSetup } : {}), ...(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 comment?: string displayName?: string workspaceStatus?: string manualOrder?: number sparseCheckout?: { directories: string[]; presetId?: string } pushTarget?: GitPushTarget runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' createdWithAgent?: TuiAgent pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance 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.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 } : {}) }, 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) 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 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 = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix') const sequenced = createSequencedSetupAgentCommands({ runnerScriptPath: result.setup.runnerScriptPath, startupCommand: args.startup.command, platform }) 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 } : {}), startupCommandDelivery: sequencedStartup.startupCommandDelivery, telemetry: sequencedStartup.telemetry }) 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}` } } const shouldActivate = args.activate === true || args.runHooks === true 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: result.setup ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' : 'posix', // 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 } // 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. void 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: result.setup ? isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix' : 'posix', ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) // 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 (result.setup) { didSpawnSetup = true } } else if (!shouldActivate && this.ptyController?.spawn) { try { await this.createTerminal(`path:${result.worktree.path}`) } 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 return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal } /** * 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++) { 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 }) const resolveRemote = sshGitProvider ? async () => { const { stdout } = await sshGitProvider.exec(['remote'], repo.path) const remotes = stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) if (remotes.includes('origin')) { return 'origin' } if (remotes.length === 1) { return remotes[0]! } if (remotes.length === 0) { throw new Error('Repo has no configured git remotes.') } throw new Error( `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured.` ) } : () => getDefaultRemote(repo.path, localWorktreeGitOptions) // Why: SSH repos can't fetch over the relay's read-only git.exec channel, so // route the PR head fetch through the write-capable helper instead of gitExec. const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => fetchPrHeadTrackingRef( repo, sshGitProvider, remote, branch, localGitExecOptions ? { localGitExecOptions } : {} ) return resolveGitHubPrStartPoint({ repoPath: repo.path, prNumber: args.prNumber, headRefName: args.headRefName, baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec, fetchRemoteTrackingRef, 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 = async (): Promise => { if (!targetBranch || !compareBaseRef) { return false } try { await fetchRemoteTrackingRef(targetBranch, compareBaseRef) return true } catch (error) { console.warn('[runtime:resolveManagedMrBase] optional compare-base fetch failed', { remote, targetBranch, mrIid: args.mrIid, error: error instanceof Error ? error.message.split('\n')[0] : String(error) }) return false } } if (isCrossRepository) { const mrRef = `refs/merge-requests/${args.mrIid}/head` // Why: GitLab exposes fork MR heads on the target project, so mobile/SSH // can match desktop without adding the contributor fork as a remote. try { await (sshGitProvider ? sshGitProvider.fetchGitLabMergeRequestHead(repo.path, remote, args.mrIid) : gitExec(['fetch', remote, mrRef])) } catch (error) { const message = error instanceof Error ? error.message : String(error) return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` } } let sha: string try { const { stdout } = await gitExec(['rev-parse', '--verify', 'FETCH_HEAD']) sha = stdout.trim() } catch { return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` } } if (!sha) { return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` } } const compareBaseFetched = await fetchCompareBaseRef() return { baseBranch: sha, ...(compareBaseFetched ? { compareBaseRef } : {}) } } 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) const remotes = stdout .split('\n') .map((line) => line.trim()) .filter(Boolean) if (remotes.includes('origin')) { return 'origin' } if (remotes.length === 1) { return remotes[0]! } if (remotes.length === 0) { throw new Error('Repo has no configured git remotes.') } throw new Error( `Repo has multiple remotes (${remotes.join(', ')}) and no default is configured.` ) } 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. store.removeWorktreeMeta(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 ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const store = this.store const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks) 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 => { const repo = store.getRepo(removalTarget.repoId) if (!repo) { throw new Error('repo_not_found') } if (isFolderRepo(repo)) { if (removalTarget.id === getRuntimeFolderWorkspaceRootId(repo)) { throw new Error( 'Cannot delete the project root workspace. Remove the folder project instead.' ) } const localProvider = this.getLocalProvider() if (localProvider) { // 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, localProvider, onPtyStopped: this.onPtyStopped ?? undefined }).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, repo.connectionId) 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) 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) 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, repo.connectionId) 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)) } const linkedPaths = repo.symlinkPaths ?? [] 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) 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 { return await removal } 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. if ( !opts.command || opts.env || opts.launchConfig || opts.launchAgent || opts.startupCommandDelivery || opts.claudeAgentTeamsSourceCommand || !workspace.repo || !this.store ) { return opts } const settings = this.store.getSettings() const platform = this.getAgentLaunchPlatformForWorkspace(workspace) const isRemote = repoIsRemote(workspace.repo) const queuedShell = resolveLocalWindowsAgentStartupShell({ platform, isRemote, terminalWindowsShell: settings.terminalWindowsShell }) const agent = 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) { 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), 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 { 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 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 ) const agentTeamsPlan = 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 }) 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 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 } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), // 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. ...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null ? { persistHostSessionBinding: true } : {}) }) reportPtySpawnCommitted() if (result.agentSessionEnsure) { const canonicalSurface = result.agentSessionEnsure.owner.surface preAllocatedHandle = canonicalSurface.terminalHandle tabId = canonicalSurface.tabId leafId = canonicalSurface.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 (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.tabId = tabId pty.paneKey = paneKey pty.launchConfig = effectiveLaunchConfig ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) : null pty.launchToken = launchToken ?? null pty.launchAgent = launchOpts.launchAgent ?? null } const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle if (pty && 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 } : {}), 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: launchOpts.title ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), ...(warning ? { warning } : {}) } } 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 } : {}) }) }) // 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 clientMutationId?: string signal?: AbortSignal } = {} ): Promise { this.assertGraphReady() 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) const win = this.getAvailableAuthoritativeWindow() if (!win) { return await this.createHeadlessMobileSessionTerminal( 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 } ) } 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, selectIfNoActiveTab: true, ...(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)) { 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) { 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.createHeadlessMobileSessionTerminal( 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 } ) } 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) { 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) } } 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 createHeadlessMobileSessionTerminal( 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 } = {} ): 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 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 } : {}), persistHostSessionBinding: true, // Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash. deferMobileSessionPublish: true }) 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) 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 pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) 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 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 ) } 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 pty = this.getLivePtyForHandle(handle) if (pty) { if (!pty.pty.connected) { throw new Error('terminal_exited') } const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '') const revealed = options.navigateHost === false ? undefined : await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { ptyId: pty.pty.ptyId, title: getLatestPtyTitle(pty.pty), ...(pty.pty.launchConfig ? { launchConfig: copySleepingAgentLaunchConfig(pty.pty.launchConfig) } : {}), ...(pty.pty.launchToken ? { launchToken: pty.pty.launchToken } : {}), ...(pty.pty.launchAgent ? { launchAgent: pty.pty.launchAgent } : {}), ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) }) return { handle, tabId: revealed?.tabId ?? pty.pty.tabId ?? pty.record.tabId, worktreeId: pty.pty.worktreeId } } this.assertGraphReady() const { leaf } = this.getLiveLeafForHandle(handle) if (options.navigateHost !== false) { this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId) } return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId } } 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) { throw new Error('terminal_tab_not_found') } // 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 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 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 } try { await this.notifier?.revealTerminalSession?.(workspace.id, { ptyId: result.id, title: null, activate: opts.activate !== false, tabId: parentTabId, leafId, splitFromLeafId: parsedPaneKey.leafId, splitDirection: direction, splitTelemetrySource: opts.telemetrySource }) } catch (error) { 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 }> } = {} ): 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 = await this.resolveWorktreeSelector(worktreeSelector) this.assertStableReadyGraph(graphEpoch) if (options.deadline !== undefined && Date.now() >= options.deadline) { return { stopped: 0 } } const ptyIds = new Set() for (const leaf of this.leaves.values()) { if (leaf.worktreeId === worktree.id && leaf.ptyId) { ptyIds.add(leaf.ptyId) } } for (const pty of this.ptysById.values()) { if (pty.worktreeId === worktree.id && pty.connected) { 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?.() ?? {} } private async resolveRepoSelector(selector: string): Promise { if (!this.store) { throw new Error('repo_not_found') } const repos = this.store.getRepos() let candidates: Repo[] if (selector.startsWith('id:')) { candidates = repos.filter((repo) => repo.id === selector.slice(3)) } else if (selector.startsWith('path:')) { candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) } else if (selector.startsWith('name:')) { candidates = repos.filter((repo) => repo.displayName === selector.slice(5)) } else { candidates = repos.filter( (repo) => repo.id === selector || runtimePathsEqual(repo.path, selector) || repo.displayName === 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. const scan = await withTimeout( this.listRepoWorktreesForResolution(repo, projectRuntimeByRepoId), RESOLVED_WORKTREE_REPO_TIMEOUT_MS, { ok: false, worktrees: [] } ) 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.listStoredSshWorktreesForResolution(repo) } } try { return { ok: true, worktrees: await provider.listWorktrees(repo.path) } } catch { return { ok: false, worktrees: this.listStoredSshWorktreesForResolution(repo) } } } private listStoredSshWorktreesForResolution(repo: Repo): GitWorktreeInfo[] { const store = this.store if (!store) { return [] } 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: mirror worktrees:list's disconnected-SSH fallback — keep persisted SSH worktrees while the provider reconnects instead of zero rows. 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' | '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, 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, managementTitle: null, managementTitleAt: 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.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> { if (targetWorktreeId === FLOATING_TERMINAL_WORKTREE_ID) { const targetedLiveness = this.refreshFloatingWorkspacePtyLiveness() if (targetedLiveness !== null) { return targetedLiveness } } if (!this.ptyController?.listProcesses) { return null } const sessionsResult = await withTimeoutResult( this.ptyController.listProcesses(), 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 sessions = sessionsResult.value const persistedWorktreeIdByPtyId = indexPersistedPtyWorktreeBindings( this.store?.getWorkspaceSession?.() ) const allLivePtyIds = new Set(sessions.map((session) => session.id)) const selectedLivePtyIds = new Set() for (const session of sessions) { this.adoptControllerTerminalHandle(session.id, session.terminalHandle) const persistedWorktreeId = persistedWorktreeIdByPtyId.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)) if ( !targetWorktreeId || (worktreeId && runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) ) { selectedLivePtyIds.add(session.id) } if ( targetWorktreeId && (!worktreeId || !runtimeWorktreeIdsEqual(worktreeId, targetWorktreeId)) ) { continue } if (worktreeId) { this.recordPtyWorktree(session.id, worktreeId, { connected: true, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}) }) } // 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 pty of this.ptysById.values()) { 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 targetWorktreeId ? selectedLivePtyIds : allLivePtyIds } 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.ptysById.delete(ptyId) this.recentPtyOutputById.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) 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 ): RuntimeTerminalSummary { const worktree = worktreesById.get(leaf.worktreeId) const tab = this.tabs.get(leaf.tabId) ?? null return { handle: this.issueHandle(leaf), ptyId: leaf.ptyId, worktreeId: leaf.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', tabId: leaf.tabId, leafId: leaf.leafId, title: getLatestLeafTitle(leaf, tab?.title ?? null), connected: leaf.connected, writable: 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 ): 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) const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds() for (const snapshot of snapshots) { worktreeIdsToHydrate.add(snapshot.worktree) } // Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition. for (const worktreeId of worktreeIdsToHydrate) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) } const nextWorktrees = new Set() 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, snapshot) ) ) { continue } const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot) 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 }) } 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 that are absent from the incoming // 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 even though the renderer revision is unchanged. private storedMobileSnapshotHasStalePreservedTab( existing: RuntimeMobileSessionTabsSnapshot, incoming: RuntimeMobileSessionTabsSnapshot ): boolean { const incomingIds = new Set( incoming.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ) return existing.tabs.some( (tab) => !this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.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.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) // 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 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 ?? retainedAgentStatus?.payload.agentType ?? null }) ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null const title = normalizeCompatibleAgentTitleForOwner( leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title, ownerAgent ) const liveTitleEvidence = leafTitle ?? ptyTitle const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) const normalizedTabAgentStatus = tab.agentStatus ? normalizeCompatibleAgentStatusEntryForOwner(tab.agentStatus, 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 const keepFullAgentStatus = normalizedTabAgentStatus && (liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent' || 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 )), ...(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 } : {}), 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 ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) if (!pty?.lastAgentStatus && !retained) { return {} } 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) if (ptyTitle !== null && ptyTitleClassification !== 'agent') { // 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 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 ?? null }) ?? 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). if (retained) { return { agentStatus: normalizeCompatibleAgentStatusEntryForOwner( { ...retained.payload, paneKey, updatedAt: retained.updatedAt, stateStartedAt: retained.stateStartedAt, stateHistory: [], ...(terminalHandle ? { terminalHandle } : {}), ...((pty?.worktreeId ?? retained.worktreeId) ? { worktreeId: pty?.worktreeId ?? retained.worktreeId } : {}), tabId: tab.parentTabId, terminalTitle }, ownerAgent ) } } const now = pty!.lastOutputAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: pty!.lastAgentStatus === 'working' ? 'working' : pty!.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', updatedAt: now, stateStartedAt: now, paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), worktreeId: pty!.worktreeId, tabId: tab.parentTabId, terminalTitle, stateHistory: [] } } } /** 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 = {} for (const leaf of this.leaves.values()) { if (!leaf.ptyId) { continue } const handle = this.issueHandle(leaf) 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) 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; completed context stale-groups future work once its done row is gone. const dispatch = db?.getActiveDispatchForTerminal?.(handle) ?? this.getRecentCompletedDispatchForTerminal(handle, db) if (!dispatch) { return undefined } const task = db?.getTask?.(dispatch.task_id) const display = typeof task?.spec === 'string' ? buildOrchestrationTaskDisplayMetadata({ spec: task.spec, taskTitle: task.task_title, displayName: task.display_name }) : { taskTitle: '', displayName: '' } const activeRun = dispatch.status === 'completed' ? undefined : db?.getActiveCoordinatorRun?.() const parentTerminalHandle = task?.created_by_terminal_handle ?? (activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle ? activeRun.coordinator_handle : undefined) const parentPaneKey = parentTerminalHandle ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) : undefined return { taskId: dispatch.task_id, dispatchId: dispatch.id, ...(display.taskTitle ? { taskTitle: display.taskTitle } : {}), ...(display.displayName ? { displayName: display.displayName } : {}), ...(parentTerminalHandle ? { parentTerminalHandle } : {}), ...(parentPaneKey ? { parentPaneKey } : {}), ...(activeRun?.coordinator_handle ? { coordinatorHandle: activeRun.coordinator_handle } : {}), ...(activeRun?.id ? { orchestrationRunId: activeRun.id } : {}) } } private getRecentCompletedDispatchForTerminal( handle: string, db = this.getOrchestrationDbIfAvailable() ): ReturnType { const dispatch = db?.getLatestDispatchForTerminal?.(handle) if (dispatch?.status !== 'completed' || !dispatch.completed_at) { 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 } let newestMatch: RuntimePtyWorktreeRecord | null = null for (const pty of this.ptysById.values()) { if (pty.paneKey === paneKey) { 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 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) } } waitForMessage( handle: string, options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } ): Promise { return new Promise((resolve) => { const timeoutMs = options?.timeoutMs ?? 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() } if (signal) { if (signal.aborted) { resolve() return } waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) signal.addEventListener('abort', onAbort, { once: true }) } waiter.timeout = setTimeout(() => { this.removeMessageWaiter(waiter) resolve() }, timeoutMs) let waiters = this.messageWaitersByHandle.get(handle) if (!waiters) { waiters = new Set() this.messageWaitersByHandle.set(handle, waiters) } waiters.add(waiter) }) } private resolveMessageWaiter(waiter: MessageWaiter): void { this.removeMessageWaiter(waiter) waiter.resolve() } 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) return { handle: this.issuePtyHandle(pty), ptyId: pty.ptyId, worktreeId: pty.worktreeId, worktreePath: worktree?.path ?? '', branch: worktree?.branch ?? '', tabId: `pty:${pty.ptyId}`, leafId: `pty:${pty.ptyId}`, 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 handle = this.adoptPreAllocatedHandle(leaf) ?? `term_${randomUUID()}` 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()}` 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.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: 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): void { if (!this._orchestrationDb) { return } const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) if (!handle) { return } const unread = this._orchestrationDb.getUndeliveredUnreadMessages(handle) if (unread.length === 0) { return } if (!leaf.writable || !leaf.ptyId) { return } const payload = formatMessagesForInjection(unread) const wrote = this.ptyController?.write(leaf.ptyId, 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. const ptyId = leaf.ptyId setTimeout(() => { try { if (!leaf.writable) { return } const submitted = this.ptyController?.write(ptyId, '\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. } }, 500) } 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() } jiraTestConnection(siteId?: string): ReturnType { return testJiraConnection(siteId) } jiraSearchIssues( jql: string, limit = 30, siteId?: JiraSiteSelection ): ReturnType { return searchJiraIssues(jql, Math.min(Math.max(1, limit), 100), siteId) } 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) } 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 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 (§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 } 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 MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 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 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 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) }