12585 lines
448 KiB
TypeScript
12585 lines
448 KiB
TypeScript
/* 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 {
|
||
extractLastOscTitle,
|
||
detectAgentStatusFromTitle,
|
||
isShellProcess
|
||
} from '../../shared/agent-detection'
|
||
import type { AgentStatus } from '../../shared/agent-detection'
|
||
import type { AgentStatusOrchestrationContext } from '../../shared/agent-status-types'
|
||
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
|
||
import { createHash, randomUUID } from 'crypto'
|
||
import { basename, isAbsolute, join } from 'path'
|
||
import { mkdir, readFile, readdir, rm, stat } from 'fs/promises'
|
||
import { OrchestrationDb } from './orchestration/db'
|
||
import { formatMessagesForInjection } from './orchestration/formatter'
|
||
import type {
|
||
Automation,
|
||
AutomationCreateInput,
|
||
AutomationRun,
|
||
AutomationUpdateInput,
|
||
AutomationWorkspaceMode
|
||
} from '../../shared/automations-types'
|
||
import type {
|
||
BaseRefSearchResult,
|
||
CreateWorktreeResult,
|
||
DetectedWorktree,
|
||
DetectedWorktreeListResult,
|
||
GitPushTarget,
|
||
GitWorktreeInfo,
|
||
GitHubOwnerRepo,
|
||
GlobalSettings,
|
||
PersistedUIState,
|
||
Repo,
|
||
StatsSummary,
|
||
Worktree,
|
||
WorktreeLineage,
|
||
WorktreeLineageWarning,
|
||
WorktreeMeta,
|
||
WorktreeBaseStatusEvent,
|
||
WorktreeRemoteBranchConflictEvent,
|
||
WorktreeStartupLaunch,
|
||
LinearIssueUpdate,
|
||
LinearWorkspaceSelection,
|
||
TabGroupLayoutNode,
|
||
TuiAgent
|
||
} from '../../shared/types'
|
||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
|
||
import { isFolderRepo } from '../../shared/repo-kind'
|
||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
|
||
import { FIRST_PANE_ID } from '../../shared/pane-key'
|
||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||
import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id'
|
||
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
|
||
import { pickTuiAgent } from '../../shared/tui-agent-selection'
|
||
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
|
||
import { detectInstalledAgents, detectRemoteAgents } from '../ipc/preflight'
|
||
import {
|
||
markCodexProjectTrusted,
|
||
markCopilotFolderTrusted,
|
||
markCursorWorkspaceTrusted
|
||
} from '../agent-trust-presets'
|
||
import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
|
||
import { upsertProjectTrustLevelInContent } from '../codex/config-toml-trust'
|
||
import {
|
||
isWindowsAbsolutePathLike,
|
||
isPathInsideOrEqual,
|
||
normalizeRuntimePathForComparison
|
||
} from '../../shared/cross-platform-path'
|
||
import {
|
||
buildKnownOrcaWorkspaceLayouts,
|
||
isLegacyRepoForExternalWorktreeVisibility,
|
||
toDetectedWorktree
|
||
} from '../../shared/worktree-ownership'
|
||
import {
|
||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||
RUNTIME_CAPABILITIES,
|
||
RUNTIME_PROTOCOL_VERSION
|
||
} from '../../shared/protocol-version'
|
||
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,
|
||
RuntimeTerminalSend,
|
||
RuntimeTerminalCreate,
|
||
RuntimeTerminalSplit,
|
||
RuntimeTerminalFocus,
|
||
RuntimeTerminalClose,
|
||
RuntimeTerminalListResult,
|
||
RuntimeTerminalState,
|
||
RuntimeStatus,
|
||
RuntimeTerminalWait,
|
||
RuntimeTerminalWaitBlockedReason,
|
||
RuntimeTerminalWaitCondition,
|
||
RuntimeWorktreePsSummary,
|
||
RuntimeWorktreeStatus,
|
||
RuntimeTerminalShow,
|
||
RuntimeTerminalSummary,
|
||
RuntimeSyncedLeaf,
|
||
RuntimeSyncedTab,
|
||
RuntimeMarkdownReadTabResult,
|
||
RuntimeMarkdownSaveTabResult,
|
||
RuntimeMobileSessionCreateTerminalResult,
|
||
RuntimeMobileSessionClientTab,
|
||
RuntimeMobileSessionMarkdownTab,
|
||
RuntimeMobileSessionTabMove,
|
||
RuntimeMobileSessionTabMoveResult,
|
||
RuntimeMobileSessionTabGroup,
|
||
RuntimeMobileSessionTerminalTab,
|
||
RuntimeMobileSessionTabsRemovedResult,
|
||
RuntimeMobileSessionTabsResult,
|
||
RuntimeMobileSessionTabsSnapshot,
|
||
RuntimeBrowserDriverState,
|
||
RuntimeTerminalDriverState,
|
||
RuntimeSyncWindowGraph,
|
||
RuntimeWorktreeListResult,
|
||
BrowserTabInfo,
|
||
BrowserScreencastResult
|
||
} from '../../shared/runtime-types'
|
||
import type { AutomationService } from '../automations/service'
|
||
import { RuntimeBrowserCommands } from './orca-runtime-browser'
|
||
import { RuntimeFileCommands } from './orca-runtime-files'
|
||
import { RuntimeGitCommands } from './orca-runtime-git'
|
||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||
import { BrowserWindow, ipcMain } from 'electron'
|
||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||
import { BrowserError } from '../browser/cdp-bridge'
|
||
import {
|
||
getPRForBranch,
|
||
getRepoSlug,
|
||
getWorkItem,
|
||
listIssues as listGitHubIssues,
|
||
listWorkItems,
|
||
countWorkItems,
|
||
getPRChecks,
|
||
getPRCheckDetails,
|
||
rerunPRChecks,
|
||
getPRComments,
|
||
getIssue,
|
||
resolveReviewThread,
|
||
setPRFileViewed,
|
||
getWorkItemByOwnerRepo,
|
||
updatePRTitle,
|
||
updatePRDetails,
|
||
mergePR,
|
||
updatePRState,
|
||
requestPRReviewers,
|
||
removePRReviewers,
|
||
createIssue,
|
||
updateIssue,
|
||
addIssueComment,
|
||
addPRReviewComment,
|
||
addPRReviewCommentReply,
|
||
listLabels,
|
||
listAssignableUsers
|
||
} from '../github/client'
|
||
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
|
||
import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details'
|
||
import { getRateLimit } from '../github/rate-limit'
|
||
import {
|
||
closeMR as closeGitLabMR,
|
||
createIssue as createGitLabIssue,
|
||
getProjectRefForRemote as getGitLabProjectRefForRemote,
|
||
getWorkItemByProjectRef as getGitLabWorkItemByProjectRef,
|
||
addIssueComment as addGitLabIssueComment,
|
||
addMRComment as addGitLabMRComment,
|
||
listTodos as listGitLabTodos,
|
||
listIssues as listGitLabIssues,
|
||
listMergeRequests as listGitLabMergeRequests,
|
||
listWorkItems as listGitLabWorkItems,
|
||
mergeMR as mergeGitLabMR,
|
||
reopenMR as reopenGitLabMR,
|
||
updateMR as updateGitLabMR,
|
||
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,
|
||
GitLabProjectRef,
|
||
GitLabWorkItem,
|
||
MRListState
|
||
} 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 {
|
||
createHostedReview as createHostedReviewFromRepo,
|
||
getHostedReviewCreationEligibility as getHostedReviewCreationEligibilityFromRepo
|
||
} from '../source-control/hosted-review-creation'
|
||
import {
|
||
connect as connectLinear,
|
||
disconnect as disconnectLinear,
|
||
getStatus as getLinearStatus,
|
||
selectWorkspace as selectLinearWorkspace,
|
||
testConnection as testLinearConnection
|
||
} from '../linear/client'
|
||
import {
|
||
addIssueComment as addLinearIssueComment,
|
||
createIssue as createLinearIssue,
|
||
getIssue as getLinearIssue,
|
||
getIssueComments as getLinearIssueComments,
|
||
listIssues as listLinearIssues,
|
||
searchIssues as searchLinearIssues,
|
||
updateIssue as updateLinearIssue,
|
||
type LinearListFilter
|
||
} from '../linear/issues'
|
||
import { listProjects as listLinearProjects } from '../linear/projects'
|
||
import {
|
||
getTeamLabels as getLinearTeamLabels,
|
||
getTeamMembers as getLinearTeamMembers,
|
||
getTeamStates as getLinearTeamStates,
|
||
listTeams as listLinearTeams
|
||
} from '../linear/teams'
|
||
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,
|
||
ListAssignableUsersBySlugArgs,
|
||
ListIssueTypesBySlugArgs,
|
||
ListLabelsBySlugArgs,
|
||
ListProjectViewsArgs,
|
||
ProjectWorkItemDetailsBySlugArgs,
|
||
ResolveProjectRefArgs,
|
||
AddIssueCommentBySlugArgs,
|
||
DeleteIssueCommentBySlugArgs,
|
||
UpdateIssueBySlugArgs,
|
||
UpdateIssueCommentBySlugArgs,
|
||
UpdateIssueTypeBySlugArgs,
|
||
UpdateProjectItemFieldArgs,
|
||
UpdatePullRequestBySlugArgs
|
||
} from '../../shared/github-project-types'
|
||
import {
|
||
getGitUsername,
|
||
getBaseRefDefault,
|
||
getDefaultBaseRef,
|
||
getDefaultRemote,
|
||
getBranchConflictKind,
|
||
isGitRepo,
|
||
getRepoName,
|
||
searchBaseRefDetails,
|
||
getRemoteCount,
|
||
normalizeRefSearchQuery,
|
||
parseAndFilterSearchRefDetails,
|
||
parseRemoteCount,
|
||
resolveDefaultBaseRefViaExec,
|
||
buildSearchBaseRefsArgv,
|
||
getRemoteDrift,
|
||
getRecentDriftSubjects
|
||
} from '../git/repo'
|
||
import {
|
||
listWorktrees,
|
||
addWorktree,
|
||
addSparseWorktree,
|
||
assertWorktreeCleanForRemoval,
|
||
removeWorktree
|
||
} from '../git/worktree'
|
||
import { isENOENT } from '../ipc/filesystem-auth'
|
||
import {
|
||
createSetupRunnerScript,
|
||
getEffectiveHooks,
|
||
getEffectiveSetupRunPolicy,
|
||
hasUnrecognizedOrcaYamlKeys,
|
||
hasHooksFile,
|
||
loadHooks,
|
||
parseOrcaYaml,
|
||
readIssueCommand,
|
||
runHook,
|
||
shouldRunSetupForCreate,
|
||
writeIssueCommand
|
||
} from '../hooks'
|
||
import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/constants'
|
||
import { listRepoWorktrees } from '../repo-worktrees'
|
||
import { createWorktreeSymlinks } from '../ipc/worktree-symlinks'
|
||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||
import {
|
||
cleanupUnusedWorktreePushTargetRemote,
|
||
cleanupUnusedWorktreePushTargetRemoteSsh,
|
||
createRemoteWorktree,
|
||
configureCreatedWorktreePushTarget,
|
||
prepareWorktreePushTarget
|
||
} from '../ipc/worktree-remote'
|
||
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,
|
||
ensurePathWithinWorkspace,
|
||
formatWorktreeRemovalError,
|
||
isOrphanCompatiblePreflightError,
|
||
isOrphanedWorktreeError,
|
||
mergeWorktree,
|
||
sanitizeWorktreeName,
|
||
shouldSetDisplayName,
|
||
areWorktreePathsEqual
|
||
} from '../ipc/worktree-logic'
|
||
import {
|
||
canSafelyRemoveOrphanedWorktreeDirectory,
|
||
findRegisteredDeletableWorktree,
|
||
isWorktreePathMissing
|
||
} from '../worktree-removal-safety'
|
||
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
|
||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||
import { killAllProcessesForWorktree } from './worktree-teardown'
|
||
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
|
||
import type { IFilesystemProvider, IPtyProvider } from '../providers/types'
|
||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||
import { getActiveMultiplexer } from '../ipc/ssh'
|
||
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 type { RateLimitState } from '../../shared/rate-limit-types'
|
||
import type { VoiceSettings } from '../../shared/speech-types'
|
||
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||
|
||
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']
|
||
removeRepo?: Store['removeRepo']
|
||
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']
|
||
getGitHubCache: Store['getGitHubCache']
|
||
getWorkspaceSession?: Store['getWorkspaceSession']
|
||
getUI?: Store['getUI']
|
||
updateUI?: Store['updateUI']
|
||
listAutomations?: Store['listAutomations']
|
||
listAutomationRuns?: Store['listAutomationRuns']
|
||
createAutomation?: Store['createAutomation']
|
||
updateAutomation?: Store['updateAutomation']
|
||
deleteAutomation?: Store['deleteAutomation']
|
||
getSparsePresets?: Store['getSparsePresets']
|
||
saveSparsePreset?: Store['saveSparsePreset']
|
||
getSettings(): {
|
||
workspaceDir: string
|
||
nestWorkspaces: boolean
|
||
refreshLocalBaseRefOnWorktreeCreate: boolean
|
||
branchPrefix: string
|
||
branchPrefixCustom: string
|
||
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
|
||
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
|
||
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
|
||
defaultTaskSource?: GlobalSettings['defaultTaskSource']
|
||
defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset']
|
||
visibleTaskProviders?: GlobalSettings['visibleTaskProviders']
|
||
defaultRepoSelection?: GlobalSettings['defaultRepoSelection']
|
||
defaultLinearTeamSelection?: GlobalSettings['defaultLinearTeamSelection']
|
||
githubProjects?: GlobalSettings['githubProjects']
|
||
gitlabProjects?: GlobalSettings['gitlabProjects']
|
||
experimentalWorktreeSymlinks?: boolean
|
||
mobileAutoRestoreFitMs?: number | null
|
||
voice?: VoiceSettings
|
||
}
|
||
// 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<GlobalSettings>) => 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<K extends keyof RuntimeAutomationUpdateInput>(
|
||
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[]
|
||
tailPartialLine: string
|
||
tailTruncated: boolean
|
||
tailLinesTotal: number
|
||
preview: string
|
||
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
|
||
}
|
||
|
||
type RuntimePtyWorktreeRecord = {
|
||
ptyId: string
|
||
worktreeId: string
|
||
// 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
|
||
connected: boolean
|
||
lastExitCode: number | null
|
||
lastAgentStatus: AgentStatus | null
|
||
lastOscTitle: string | null
|
||
title: string | null
|
||
lastOutputAt: number | null
|
||
tailBuffer: string[]
|
||
tailPartialLine: string
|
||
tailTruncated: boolean
|
||
tailLinesTotal: number
|
||
preview: string
|
||
}
|
||
|
||
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<void>
|
||
}
|
||
|
||
type RuntimePtyController = {
|
||
spawn?(opts: {
|
||
cols: number
|
||
rows: number
|
||
cwd?: string
|
||
command?: string
|
||
env?: Record<string, string>
|
||
connectionId?: string | null
|
||
worktreeId?: string
|
||
preAllocatedHandle?: string
|
||
}): Promise<{ id: string }>
|
||
write(ptyId: string, data: string): boolean
|
||
kill(ptyId: string): boolean
|
||
getForegroundProcess(ptyId: string): Promise<string | null>
|
||
hasChildProcesses?(ptyId: string): Promise<boolean>
|
||
clearBuffer?(ptyId: string): Promise<void>
|
||
resize?(ptyId: string, cols: number, rows: number): boolean
|
||
listProcesses?(): Promise<{ id: string; cwd: string; title: string }[]>
|
||
serializeBuffer?(
|
||
ptyId: string,
|
||
opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }
|
||
): Promise<{ data: string; cols: number; rows: number; lastTitle?: string } | null>
|
||
// 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
|
||
getSize?(ptyId: string): { cols: number; rows: number } | null
|
||
}
|
||
|
||
type WorktreeStartupDraftPaste = {
|
||
agent: TuiAgent
|
||
content: string
|
||
}
|
||
|
||
const DECSET_BRACKETED_PASTE = '\x1b[?2004h'
|
||
const CODEX_COMPOSER_PROMPT = '›'
|
||
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 RECENT_PTY_OUTPUT_LIMIT = 4096
|
||
|
||
type RuntimeNotifier = {
|
||
worktreesChanged(repoId: string): void
|
||
worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void
|
||
worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void
|
||
reposChanged(): void
|
||
activateWorktree(
|
||
repoId: string,
|
||
worktreeId: string,
|
||
setup?: CreateWorktreeResult['setup'],
|
||
startup?: WorktreeStartupLaunch
|
||
): void
|
||
createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void
|
||
revealTerminalSession?(
|
||
worktreeId: string,
|
||
opts: {
|
||
ptyId: string
|
||
title?: string | null
|
||
activate?: boolean
|
||
tabId?: string
|
||
leafId?: string
|
||
splitFromLeafId?: string
|
||
splitDirection?: 'horizontal' | 'vertical'
|
||
}
|
||
):
|
||
| Promise<{ tabId: string; title?: string | null }>
|
||
| { tabId: string; title?: string | null }
|
||
| void
|
||
splitTerminal(
|
||
tabId: string,
|
||
paneRuntimeId: number,
|
||
opts: { direction: 'horizontal' | 'vertical'; command?: string }
|
||
): 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): void
|
||
openDiff?(worktreeId: string, filePath: string, relativePath: string, staged: boolean): void
|
||
readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise<RuntimeMarkdownReadTabResult>
|
||
saveMobileMarkdownTab?(
|
||
worktreeId: string,
|
||
tabId: string,
|
||
baseVersion: string,
|
||
content: string
|
||
): Promise<RuntimeMarkdownSaveTabResult>
|
||
closeTerminal(tabId: string, paneRuntimeId?: number): void
|
||
sleepWorktree(worktreeId: string): void
|
||
terminalFitOverrideChanged(
|
||
ptyId: string,
|
||
mode: 'mobile-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
|
||
}
|
||
|
||
function omitUndefinedProperties<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||
return Object.fromEntries(
|
||
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
||
) as Partial<T>
|
||
}
|
||
|
||
async function isRuntimeWorktreePathMissing(repo: Repo, worktreePath: string): Promise<boolean> {
|
||
if (!repo.connectionId) {
|
||
return isWorktreePathMissing(worktreePath)
|
||
}
|
||
|
||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||
if (!fsProvider) {
|
||
return false
|
||
}
|
||
return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path))
|
||
}
|
||
|
||
type RuntimeWorktreeRemovalTarget = {
|
||
id: string
|
||
repoId: string
|
||
path: string
|
||
pushTarget?: GitPushTarget
|
||
}
|
||
|
||
type RuntimeWorktreeRemovalInFlight = {
|
||
optionsKey: string
|
||
promise: Promise<{ warning?: string }>
|
||
}
|
||
|
||
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}`
|
||
}
|
||
|
||
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,
|
||
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,
|
||
linkedGitLabMR: meta.linkedGitLabMR ?? null,
|
||
linkedGitLabIssue: meta.linkedGitLabIssue ?? 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 } : {}),
|
||
workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID,
|
||
diffComments: meta.diffComments
|
||
}
|
||
}
|
||
|
||
function listRuntimeFolderWorkspaces(
|
||
store: Pick<RuntimeStore, 'getAllWorktreeMeta' | 'setWorktreeMeta'>,
|
||
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
|
||
): Promise<string> {
|
||
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 })
|
||
return branchNameOverride
|
||
}
|
||
|
||
function normalizeLocalBranchName(branchName: string | undefined): string {
|
||
return branchName?.replace(/^refs\/heads\//, '') ?? ''
|
||
}
|
||
|
||
async function canCheckoutExistingLocalBranch(
|
||
repoPath: string,
|
||
branchName: string,
|
||
baseBranch: string
|
||
): Promise<boolean> {
|
||
if (normalizeLocalBranchName(baseBranch) !== branchName) {
|
||
return false
|
||
}
|
||
try {
|
||
await gitExecFileAsync(
|
||
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
|
||
{
|
||
cwd: repoPath
|
||
}
|
||
)
|
||
} catch {
|
||
return false
|
||
}
|
||
const worktrees = await listWorktrees(repoPath)
|
||
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
|
||
}
|
||
|
||
type ResolvedWorktree = Worktree & {
|
||
parentWorktreeId: string | null
|
||
childWorktreeIds: string[]
|
||
lineage: WorktreeLineage | null
|
||
git: GitWorktreeInfo
|
||
}
|
||
|
||
type WorktreeLineageInput = {
|
||
parentWorktree?: string
|
||
cwdParentWorktree?: string
|
||
noParent?: boolean
|
||
callerTerminalHandle?: string
|
||
comment?: string
|
||
orchestrationContext?: {
|
||
parentWorktreeId?: string
|
||
orchestrationRunId?: string
|
||
taskId?: string
|
||
coordinatorHandle?: string
|
||
}
|
||
}
|
||
|
||
type WorktreeLineageResolution =
|
||
| {
|
||
kind: 'lineage'
|
||
parent: ResolvedWorktree
|
||
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 WorktreeLineageCandidate = {
|
||
source: 'cwd-context' | 'terminal-context' | 'orchestration-context'
|
||
parent: ResolvedWorktree
|
||
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
|
||
}
|
||
}
|
||
|
||
type ResolvedWorktreeCache = {
|
||
expiresAt: number
|
||
worktrees: ResolvedWorktree[]
|
||
}
|
||
|
||
type ResolvedWorktreeInFlight = {
|
||
generation: number
|
||
promise: Promise<ResolvedWorktree[]>
|
||
}
|
||
|
||
export type MobileNotificationEvent = {
|
||
source: 'agent-task-complete' | 'terminal-bell' | 'test'
|
||
title: string
|
||
body: string
|
||
worktreeId?: string
|
||
}
|
||
|
||
// 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 }
|
||
|
||
// 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<ApplyLayoutResult> | null
|
||
pending: {
|
||
target: PtyLayoutTarget
|
||
waiters: ((r: ApplyLayoutResult) => void)[]
|
||
}[]
|
||
}
|
||
|
||
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<string, RuntimeSyncedTab>()
|
||
private mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
|
||
private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>()
|
||
private leaves = new Map<string, RuntimeLeafRecord>()
|
||
private handles = new Map<string, TerminalHandleRecord>()
|
||
private handleByLeafKey = new Map<string, string>()
|
||
private handleByPtyId = new Map<string, string>()
|
||
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
|
||
private graphSyncCallbacks: (() => void)[] = []
|
||
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
|
||
private ptyController: RuntimePtyController | null = null
|
||
private notifier: RuntimeNotifier | null = null
|
||
private agentBrowserBridge: AgentBrowserBridge | null = null
|
||
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
|
||
private resolvedWorktreeInFlight: ResolvedWorktreeInFlight | null = null
|
||
private resolvedWorktreeGeneration = 0
|
||
private agentDetector: AgentDetector | null = null
|
||
private _orchestrationDb: OrchestrationDb | null = null
|
||
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
|
||
// 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) => 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<string, string>()
|
||
// 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' | 'desktop-fit'; cols: number; rows: number }) => void>
|
||
>()
|
||
private driverListeners = new Map<string, Set<(driver: DriverState) => void>>()
|
||
private subscriptionCleanups = new Map<string, () => void>()
|
||
// 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<string, Set<string>>()
|
||
private subscriptionConnectionByEntry = new Map<string, string>()
|
||
private activeBrowserScreencastsByConnection = new Map<
|
||
string,
|
||
{ cancel: (emitEnd?: boolean) => void; done: Promise<void>; connectionKey: string }
|
||
>()
|
||
private activeBrowserScreencastsByPage = new Map<
|
||
string,
|
||
{ cancel: (emitEnd?: boolean) => void; done: Promise<void>; 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<string, RuntimePtyWorktreeRecord>()
|
||
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
|
||
private ptyOutputSequenceById = new Map<string, number>()
|
||
// 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<string, 'pending' | 'done'>()
|
||
// 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<string, 'desktop'>()
|
||
|
||
// 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: 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<string, DriverState>()
|
||
private currentBrowserDriver = new Map<string, RuntimeBrowserDriverState>()
|
||
|
||
// 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<typeof setTimeout>
|
||
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<string, { cols: number; rows: number }>()
|
||
|
||
// 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<typeof setTimeout>; 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<string, PtyLayoutState>()
|
||
|
||
// 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<string, LayoutQueueEntry>()
|
||
|
||
// 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<string>()
|
||
|
||
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 `<repoPath>::<remote>` 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 but intentionally do not write the
|
||
// full-remote freshness timestamp.
|
||
private fetchInflight = new Map<string, Promise<RemoteFetchResult>>()
|
||
// Why: `git fetch origin` and `git fetch origin <refspec>` 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<string, Promise<RemoteFetchResult>>()
|
||
private fetchLastCompletedAt = new Map<string, number>()
|
||
// 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 `<repoPath>::<remote>` so the
|
||
// canonical key is resolved at most once per repo+remote in the process.
|
||
private canonicalFetchKeyCache = new Map<string, string>()
|
||
private optimisticReconcileTokens = new Map<string, string>()
|
||
private removeManagedWorktreeInFlight = new Map<string, RuntimeWorktreeRemovalInFlight>()
|
||
private readonly getLocalProviderFn: (() => IPtyProvider) | null
|
||
private accountServices: RuntimeAccountServices | null = null
|
||
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
|
||
private automationService: AutomationService | null = null
|
||
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 }
|
||
) {
|
||
this.store = store
|
||
if (stats) {
|
||
this.stats = stats
|
||
this.agentDetector = new AgentDetector(stats)
|
||
}
|
||
// 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
|
||
}
|
||
|
||
getLocalProvider(): IPtyProvider | null {
|
||
return this.getLocalProviderFn ? this.getLocalProviderFn() : null
|
||
}
|
||
|
||
getStatsSummary(): StatsSummary | null {
|
||
return this.stats?.getSummary() ?? null
|
||
}
|
||
|
||
getUIState(): PersistedUIState {
|
||
if (!this.store?.getUI) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
return this.store.getUI()
|
||
}
|
||
|
||
updateUIState(updates: Partial<PersistedUIState>): PersistedUIState {
|
||
if (!this.store?.getUI || !this.store.updateUI) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
this.store.updateUI(updates)
|
||
return this.store.getUI()
|
||
}
|
||
|
||
getClientSettings(): Pick<
|
||
GlobalSettings,
|
||
| 'defaultTuiAgent'
|
||
| 'agentCmdOverrides'
|
||
| 'agentStatusHooksEnabled'
|
||
| 'defaultTaskSource'
|
||
| 'defaultTaskViewPreset'
|
||
| 'visibleTaskProviders'
|
||
| 'defaultRepoSelection'
|
||
| 'defaultLinearTeamSelection'
|
||
| 'githubProjects'
|
||
> {
|
||
if (!this.store?.getSettings) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
const settings = this.store.getSettings()
|
||
return {
|
||
defaultTuiAgent: settings.defaultTuiAgent ?? null,
|
||
agentCmdOverrides: settings.agentCmdOverrides ?? {},
|
||
agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false,
|
||
defaultTaskSource: settings.defaultTaskSource ?? 'github',
|
||
defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues',
|
||
visibleTaskProviders: settings.visibleTaskProviders ?? ['github', 'gitlab', 'linear'],
|
||
defaultRepoSelection: settings.defaultRepoSelection ?? null,
|
||
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
|
||
githubProjects: settings.githubProjects
|
||
}
|
||
}
|
||
|
||
updateClientSettings(
|
||
updates: Pick<
|
||
Partial<GlobalSettings>,
|
||
| 'agentStatusHooksEnabled'
|
||
| 'defaultTaskSource'
|
||
| 'defaultTaskViewPreset'
|
||
| 'defaultRepoSelection'
|
||
| 'defaultLinearTeamSelection'
|
||
| 'githubProjects'
|
||
>
|
||
): Pick<
|
||
GlobalSettings,
|
||
| 'defaultTuiAgent'
|
||
| 'agentCmdOverrides'
|
||
| 'agentStatusHooksEnabled'
|
||
| 'defaultTaskSource'
|
||
| 'defaultTaskViewPreset'
|
||
| 'visibleTaskProviders'
|
||
| 'defaultRepoSelection'
|
||
| 'defaultLinearTeamSelection'
|
||
| 'githubProjects'
|
||
> {
|
||
if (!this.store?.getSettings || !this.store.updateSettings) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
const before = this.store.getSettings().agentStatusHooksEnabled !== false
|
||
this.store.updateSettings(updates)
|
||
if (
|
||
typeof updates.agentStatusHooksEnabled === 'boolean' &&
|
||
before !== updates.agentStatusHooksEnabled
|
||
) {
|
||
applyAgentStatusHooksEnabled(updates.agentStatusHooksEnabled)
|
||
}
|
||
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<Automation> {
|
||
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,
|
||
agentId: input.agentId,
|
||
projectId: target.projectId,
|
||
workspaceMode: target.workspaceMode,
|
||
workspaceId: target.workspaceId,
|
||
baseBranch: input.baseBranch,
|
||
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<Automation> {
|
||
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, 'agentId')) {
|
||
patch.agentId = updates.agentId
|
||
}
|
||
if (hasRuntimeAutomationUpdateValue(updates, 'baseBranch')) {
|
||
patch.baseBranch = updates.baseBranch
|
||
}
|
||
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<AutomationRun> {
|
||
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
|
||
}
|
||
|
||
getStatus(): RuntimeStatus {
|
||
return {
|
||
runtimeId: this.runtimeId,
|
||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||
graphStatus: this.graphStatus,
|
||
authoritativeWindowId: this.authoritativeWindowId,
|
||
liveTabCount: this.tabs.size,
|
||
liveLeafCount: this.leaves.size,
|
||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||
capabilities: [...RUNTIME_CAPABILITIES],
|
||
protocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||
minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void {
|
||
this.agentBrowserBridge = bridge
|
||
}
|
||
|
||
getAgentBrowserBridge(): AgentBrowserBridge | null {
|
||
return this.agentBrowserBridge
|
||
}
|
||
|
||
attachWindow(windowId: number): void {
|
||
if (this.authoritativeWindowId === null) {
|
||
this.authoritativeWindowId = windowId
|
||
}
|
||
}
|
||
|
||
syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeStatus {
|
||
if (this.authoritativeWindowId === null) {
|
||
this.authoritativeWindowId = windowId
|
||
}
|
||
if (windowId !== this.authoritativeWindowId) {
|
||
throw new Error('Runtime graph publisher does not match the authoritative window')
|
||
}
|
||
|
||
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
|
||
this.syncMobileSessionTabs(graph.mobileSessionTabs)
|
||
const nextLeaves = new Map<string, RuntimeLeafRecord>()
|
||
|
||
// 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 graph.leaves) {
|
||
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)
|
||
|
||
nextLeaves.set(leafKey, {
|
||
...leaf,
|
||
ptyId,
|
||
ptyGeneration,
|
||
connected: ptyId !== null,
|
||
writable: this.graphStatus === 'ready' && ptyId !== null,
|
||
lastOutputAt: existing?.ptyId === ptyId ? existing.lastOutputAt : null,
|
||
lastExitCode: existing?.ptyId === ptyId ? existing.lastExitCode : null,
|
||
tailBuffer: existing?.ptyId === ptyId ? existing.tailBuffer : [],
|
||
tailPartialLine: existing?.ptyId === ptyId ? existing.tailPartialLine : '',
|
||
tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false,
|
||
tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0,
|
||
preview: existing?.ptyId === ptyId ? existing.preview : '',
|
||
lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null,
|
||
lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null
|
||
})
|
||
|
||
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)) {
|
||
this.invalidateLeafHandle(leafKey)
|
||
}
|
||
}
|
||
|
||
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)
|
||
) {
|
||
// 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)
|
||
} else {
|
||
this.invalidateLeafHandle(oldLeafKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
const nextPtyIds = new Set(
|
||
[...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId)
|
||
)
|
||
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.notifyMobileSessionTabSnapshots()
|
||
this.graphStatus = 'ready'
|
||
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()
|
||
}
|
||
|
||
return this.getStatus()
|
||
}
|
||
|
||
async listMobileSessionTabs(worktreeSelector: string): Promise<RuntimeMobileSessionTabsResult> {
|
||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||
await this.refreshMobileSessionPtyRecords()
|
||
if (explicitWorktreeId) {
|
||
return this.getMobileSessionTabsForWorktree(explicitWorktreeId)
|
||
}
|
||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||
return this.getMobileSessionTabsForWorktree(worktree.id)
|
||
}
|
||
|
||
async listAllMobileSessionTabs(): Promise<RuntimeMobileSessionTabsResult[]> {
|
||
await this.refreshMobileSessionPtyRecords()
|
||
return [...this.mobileSessionTabsByWorktree.values()].map((snapshot) =>
|
||
this.toMobileSessionTabsResult(snapshot)
|
||
)
|
||
}
|
||
|
||
private async refreshMobileSessionPtyRecords(): Promise<void> {
|
||
if (!this.ptyController?.listProcesses) {
|
||
return
|
||
}
|
||
const resolvedWorktrees = await this.listResolvedWorktrees()
|
||
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
|
||
}
|
||
|
||
async activateMobileSessionTab(
|
||
worktreeSelector: string,
|
||
tabId: string
|
||
): Promise<RuntimeMobileSessionTabsResult> {
|
||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||
const worktreeId =
|
||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||
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 (tab.type === 'terminal') {
|
||
const activeSibling =
|
||
tab.id === tabId
|
||
? null
|
||
: snapshot?.tabs.find(
|
||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||
candidate.type === 'terminal' &&
|
||
candidate.parentTabId === tab.parentTabId &&
|
||
candidate.isActive
|
||
)
|
||
const targetTab = activeSibling ?? tab
|
||
this.notifier?.focusTerminal(targetTab.parentTabId, worktreeId, targetTab.leafId)
|
||
} 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.
|
||
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
|
||
} else {
|
||
this.notifier?.focusEditorTab?.(tab.id, worktreeId)
|
||
}
|
||
return this.getMobileSessionTabsForWorktree(worktreeId)
|
||
}
|
||
|
||
async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> {
|
||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||
const worktreeId =
|
||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||
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 (tab.type === 'terminal') {
|
||
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 {
|
||
this.notifier?.closeSessionTab?.(tab.id, worktreeId)
|
||
}
|
||
return { closed: true }
|
||
}
|
||
|
||
async moveMobileSessionTab(
|
||
worktreeSelector: string,
|
||
move: RuntimeMobileSessionTabMove
|
||
): Promise<RuntimeMobileSessionTabMoveResult> {
|
||
const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector)
|
||
const worktreeId =
|
||
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||
if (!this.notifier?.moveSessionTab) {
|
||
throw new Error('renderer_unavailable')
|
||
}
|
||
if (!snapshot) {
|
||
throw new Error('tab_not_found')
|
||
}
|
||
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 }
|
||
}
|
||
|
||
private normalizeMobileSessionTabOrder(
|
||
snapshot: RuntimeMobileSessionTabsSnapshot | undefined,
|
||
targetGroup: RuntimeMobileSessionTabGroup,
|
||
tabOrder: readonly string[]
|
||
): string[] {
|
||
const normalized: string[] = []
|
||
const seen = new Set<string>()
|
||
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<string> {
|
||
const ids = new Set<string>()
|
||
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<RuntimeMarkdownReadTabResult> {
|
||
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<RuntimeMarkdownSaveTabResult> {
|
||
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),
|
||
resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector),
|
||
openFile: (worktreeId, filePath, relativePath) => {
|
||
if (!this.notifier?.openFile) {
|
||
throw new Error('renderer_unavailable')
|
||
}
|
||
this.notifier.openFile(worktreeId, filePath, relativePath)
|
||
},
|
||
openDiff: (worktreeId, filePath, relativePath, staged) => {
|
||
if (!this.notifier?.openDiff) {
|
||
throw new Error('renderer_unavailable')
|
||
}
|
||
this.notifier.openDiff(worktreeId, filePath, relativePath, staged)
|
||
}
|
||
})
|
||
|
||
listMobileFiles: RuntimeFileCommands['listMobileFiles'] = this.fileCommands.listMobileFiles.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
|
||
)
|
||
readFileExplorerDir: RuntimeFileCommands['readFileExplorerDir'] =
|
||
this.fileCommands.readFileExplorerDir.bind(this.fileCommands)
|
||
watchFileExplorer: RuntimeFileCommands['watchFileExplorer'] =
|
||
this.fileCommands.watchFileExplorer.bind(this.fileCommands)
|
||
readFileExplorerPreview: RuntimeFileCommands['readFileExplorerPreview'] =
|
||
this.fileCommands.readFileExplorerPreview.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)
|
||
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)
|
||
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
|
||
)
|
||
pullRuntimeGit: RuntimeGitCommands['pullRuntimeGit'] = this.gitCommands.pullRuntimeGit.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)
|
||
|
||
private async resolveRuntimeGitTarget(
|
||
worktreeSelector: string
|
||
): Promise<{ worktree: ResolvedWorktree; connectionId?: string }> {
|
||
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
|
||
): () => void {
|
||
this.mobileSessionTabListeners.add(listener)
|
||
return () => {
|
||
this.mobileSessionTabListeners.delete(listener)
|
||
}
|
||
}
|
||
|
||
// 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.leaves.values()) {
|
||
if (leaf.ptyId === ptyId) {
|
||
this.adoptPreAllocatedHandle(leaf)
|
||
}
|
||
}
|
||
}
|
||
|
||
onPtySpawned(ptyId: string): void {
|
||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||
if (pty) {
|
||
pty.connected = true
|
||
}
|
||
for (const leaf of this.leaves.values()) {
|
||
if (leaf.ptyId === ptyId) {
|
||
leaf.connected = true
|
||
leaf.writable = this.graphStatus === 'ready'
|
||
this.adoptPreAllocatedHandle(leaf)
|
||
}
|
||
}
|
||
}
|
||
|
||
registerPty(ptyId: string, worktreeId: string): void {
|
||
this.recordPtyWorktree(ptyId, worktreeId, { connected: true })
|
||
}
|
||
|
||
onPtyData(ptyId: string, data: string, at: number): number {
|
||
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length
|
||
this.ptyOutputSequenceById.set(ptyId, outputSequence)
|
||
this.recentPtyOutputById.set(
|
||
ptyId,
|
||
`${this.recentPtyOutputById.get(ptyId) ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT)
|
||
)
|
||
// 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)
|
||
// 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)
|
||
this.trackHeadlessTerminalData(ptyId, data, outputSequence)
|
||
|
||
// Why: extract OSC title from raw PTY data before tail-buffer processing
|
||
// strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.)
|
||
// announce status via OSC 0/1/2 title sequences — this is the same
|
||
// detection path the renderer uses for notifications and sidebar badges.
|
||
const oscTitle = extractLastOscTitle(data)
|
||
const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null
|
||
|
||
let normalizedData: string | null = null
|
||
const getNormalizedData = (): string => {
|
||
normalizedData ??= normalizeTerminalChunk(data)
|
||
return normalizedData
|
||
}
|
||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||
const ptyTailBefore = pty
|
||
? {
|
||
lines: pty.tailBuffer,
|
||
partialLine: pty.tailPartialLine,
|
||
truncated: pty.tailTruncated,
|
||
linesTotal: pty.tailLinesTotal
|
||
}
|
||
: null
|
||
let ptyTailAfter: ReturnType<typeof appendNormalizedToTailBuffer> | null = null
|
||
if (pty) {
|
||
pty.connected = true
|
||
pty.lastOutputAt = at
|
||
const nextTail = appendNormalizedToTailBuffer(
|
||
pty.tailBuffer,
|
||
pty.tailPartialLine,
|
||
getNormalizedData()
|
||
)
|
||
ptyTailAfter = nextTail
|
||
pty.tailBuffer = nextTail.lines
|
||
pty.tailPartialLine = nextTail.partialLine
|
||
pty.tailTruncated = pty.tailTruncated || nextTail.truncated
|
||
pty.tailLinesTotal += nextTail.newCompleteLines
|
||
pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine)
|
||
if (oscTitle !== null) {
|
||
const prevStatus = pty.lastAgentStatus
|
||
pty.lastOscTitle = oscTitle
|
||
pty.lastAgentStatus = agentStatus
|
||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const leaf of this.leaves.values()) {
|
||
if (leaf.ptyId !== ptyId) {
|
||
continue
|
||
}
|
||
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.tailPartialLine,
|
||
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.tailPartialLine = pty.tailPartialLine
|
||
leaf.tailTruncated = pty.tailTruncated
|
||
leaf.tailLinesTotal = pty.tailLinesTotal
|
||
leaf.preview = pty.preview
|
||
} else {
|
||
const nextTail = appendNormalizedToTailBuffer(
|
||
leaf.tailBuffer,
|
||
leaf.tailPartialLine,
|
||
getNormalizedData()
|
||
)
|
||
leaf.tailBuffer = nextTail.lines
|
||
leaf.tailPartialLine = nextTail.partialLine
|
||
leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated
|
||
leaf.tailLinesTotal += nextTail.newCompleteLines
|
||
leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine)
|
||
}
|
||
|
||
if (oscTitle !== null) {
|
||
// 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 = oscTitle
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
const listeners = this.dataListeners.get(ptyId)
|
||
if (listeners) {
|
||
for (const listener of listeners) {
|
||
listener(data)
|
||
}
|
||
}
|
||
return outputSequence
|
||
}
|
||
|
||
getPtyOutputSequence(ptyId: string): number {
|
||
return this.ptyOutputSequenceById.get(ptyId) ?? 0
|
||
}
|
||
|
||
subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void {
|
||
let listeners = this.dataListeners.get(ptyId)
|
||
if (!listeners) {
|
||
listeners = new Set()
|
||
this.dataListeners.set(ptyId, listeners)
|
||
}
|
||
listeners.add(listener)
|
||
return () => {
|
||
listeners.delete(listener)
|
||
if (listeners.size === 0) {
|
||
this.dataListeners.delete(ptyId)
|
||
}
|
||
}
|
||
}
|
||
|
||
subscribeToFitOverrideChanges(
|
||
ptyId: string,
|
||
listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void
|
||
): () => void {
|
||
let listeners = this.fitOverrideListeners.get(ptyId)
|
||
if (!listeners) {
|
||
listeners = new Set()
|
||
this.fitOverrideListeners.set(ptyId, listeners)
|
||
}
|
||
listeners.add(listener)
|
||
return () => {
|
||
listeners.delete(listener)
|
||
if (listeners.size === 0) {
|
||
this.fitOverrideListeners.delete(ptyId)
|
||
}
|
||
}
|
||
}
|
||
|
||
subscribeToDriverChanges(ptyId: string, listener: (driver: DriverState) => void): () => void {
|
||
let listeners = this.driverListeners.get(ptyId)
|
||
if (!listeners) {
|
||
listeners = new Set()
|
||
this.driverListeners.set(ptyId, listeners)
|
||
}
|
||
listeners.add(listener)
|
||
return () => {
|
||
listeners.delete(listener)
|
||
if (listeners.size === 0) {
|
||
this.driverListeners.delete(ptyId)
|
||
}
|
||
}
|
||
}
|
||
|
||
private notifyFitOverrideListeners(
|
||
ptyId: string,
|
||
mode: 'mobile-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 } | null> {
|
||
return this.serializeTerminalBufferFromAvailableState(ptyId, opts)
|
||
}
|
||
|
||
serializeMainTerminalBuffer(
|
||
ptyId: string,
|
||
opts: { scrollbackRows?: number } = {}
|
||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
|
||
return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true })
|
||
}
|
||
|
||
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: 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 }): 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.
|
||
return
|
||
}
|
||
const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||
const state: RuntimeHeadlessTerminal = {
|
||
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
|
||
outputSequence: 0,
|
||
writeChain: Promise.resolve()
|
||
}
|
||
this.headlessTerminals.set(ptyId, state)
|
||
state.writeChain = state.writeChain
|
||
.then(() => state.emulator.write(data))
|
||
.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
|
||
}
|
||
if (this.headlessTerminals.has(ptyId)) {
|
||
// 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
|
||
}
|
||
|
||
this.headlessHydrationState.set(ptyId, 'pending')
|
||
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||
const state: RuntimeHeadlessTerminal = {
|
||
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
|
||
outputSequence: 0,
|
||
writeChain: Promise.resolve()
|
||
}
|
||
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
|
||
}
|
||
// 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)
|
||
}
|
||
if (rendered.lastTitle) {
|
||
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
|
||
}
|
||
} 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
|
||
}
|
||
const status = detectAgentStatusFromTitle(title)
|
||
for (const leaf of this.leaves.values()) {
|
||
if (leaf.ptyId === 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 = title
|
||
if (status !== null) {
|
||
leaf.lastAgentStatus = status
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void {
|
||
const state = this.getOrCreateHeadlessTerminal(ptyId)
|
||
state.writeChain = state.writeChain
|
||
.then(async () => {
|
||
await state.emulator.write(data)
|
||
state.outputSequence = outputSequence
|
||
})
|
||
.catch(() => {
|
||
// Best-effort state tracking; live streaming must continue even if
|
||
// xterm rejects a malformed or raced write during shutdown.
|
||
})
|
||
}
|
||
|
||
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: RuntimeHeadlessTerminal = {
|
||
emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }),
|
||
outputSequence: 0,
|
||
writeChain: Promise.resolve()
|
||
}
|
||
this.headlessTerminals.set(ptyId, state)
|
||
return state
|
||
}
|
||
|
||
private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void {
|
||
this.headlessTerminals.get(ptyId)?.emulator.resize(cols, rows)
|
||
}
|
||
|
||
private async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
|
||
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; seq?: number } | null> {
|
||
const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts)
|
||
if (headlessSnapshot) {
|
||
return headlessSnapshot
|
||
}
|
||
|
||
let rendererSnapshot: {
|
||
data: string
|
||
cols: number
|
||
rows: number
|
||
lastTitle?: string
|
||
} | null = null
|
||
try {
|
||
// Why: read-fallback wants visible alt-screen content (e.g. an active
|
||
// TUI like vim) so altScreenForcesZeroRows is FALSE here. Hydration is
|
||
// the only path that suppresses alt-screen scrollback. See
|
||
// docs/mobile-prefer-renderer-scrollback.md.
|
||
rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, {
|
||
scrollbackRows: opts.scrollbackRows,
|
||
altScreenForcesZeroRows: false
|
||
}) ?? Promise.resolve(null))
|
||
} catch {
|
||
// Why: mobile scrollback should not depend on a mounted renderer pane.
|
||
// If renderer serialization races reload/unmount, the runtime snapshot
|
||
// below can still preserve colored terminal state.
|
||
}
|
||
if (rendererSnapshot && rendererSnapshot.data.length > 0) {
|
||
return rendererSnapshot
|
||
}
|
||
return rendererSnapshot
|
||
}
|
||
|
||
private async serializeHeadlessTerminalBuffer(
|
||
ptyId: string,
|
||
opts: { scrollbackRows?: number; includeEmpty?: boolean } = {}
|
||
): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> {
|
||
const state = this.headlessTerminals.get(ptyId)
|
||
if (!state) {
|
||
return null
|
||
}
|
||
await state.writeChain
|
||
// Why: when an alternate-screen TUI (Claude Code, vim, etc.) is currently
|
||
// active, the visible content is the alt-screen snapshot — replaying any
|
||
// normal-buffer scrollback before it can duplicate shell prompts and
|
||
// flatten SGR attributes when the mobile xterm replays the data. Force
|
||
// scrollbackRows=0 in that case. When the buffer is in normal mode the
|
||
// caller can request scrollback so the user can scroll up to see prior
|
||
// agent output.
|
||
const requested = opts.scrollbackRows ?? 0
|
||
const scrollbackRows = state.emulator.isAlternateScreen ? 0 : requested
|
||
const snapshot = state.emulator.getSnapshot({ scrollbackRows })
|
||
const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||
return data.length > 0 || opts.includeEmpty === true
|
||
? { data, cols: snapshot.cols, rows: snapshot.rows, seq: state.outputSequence }
|
||
: null
|
||
}
|
||
|
||
private disposeHeadlessTerminal(ptyId: string): void {
|
||
this.headlessHydrationState.delete(ptyId)
|
||
const state = this.headlessTerminals.get(ptyId)
|
||
if (!state) {
|
||
return
|
||
}
|
||
this.headlessTerminals.delete(ptyId)
|
||
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 }
|
||
}
|
||
|
||
registerSubscriptionCleanup(
|
||
subscriptionId: string,
|
||
cleanup: () => void,
|
||
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) {
|
||
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 {
|
||
const cleanup = this.subscriptionCleanups.get(subscriptionId)
|
||
if (cleanup) {
|
||
this.subscriptionCleanups.delete(subscriptionId)
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
cleanup()
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
this.cleanupSubscription(id)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
dispatchMobileNotification(event: MobileNotificationEvent): void {
|
||
for (const listener of this.notificationListeners) {
|
||
listener(event)
|
||
}
|
||
}
|
||
|
||
// ─── Account Services (mobile RPC bridge) ─────────────────────
|
||
|
||
setAccountServices(services: RuntimeAccountServices): void {
|
||
this.accountServices = services
|
||
}
|
||
|
||
setCommitMessageAgentEnvironmentResolvers(
|
||
resolvers: CommitMessageAgentEnvironmentResolvers
|
||
): void {
|
||
this.commitMessageAgentEnv = resolvers
|
||
}
|
||
|
||
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<typeof this.mobileDictation>): 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<void> {
|
||
const { rateLimits } = this.requireAccountServices()
|
||
await Promise.allSettled([
|
||
rateLimits.refresh(),
|
||
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
|
||
rateLimits.fetchInactiveCodexAccountsOnOpen()
|
||
])
|
||
}
|
||
|
||
selectClaudeAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
|
||
return this.requireAccountServices().claudeAccounts.selectAccount(accountId)
|
||
}
|
||
|
||
selectCodexAccount(accountId: string | null): Promise<CodexRateLimitAccountsState> {
|
||
return this.requireAccountServices().codexAccounts.selectAccount(accountId)
|
||
}
|
||
|
||
removeClaudeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||
return this.requireAccountServices().claudeAccounts.removeAccount(accountId)
|
||
}
|
||
|
||
removeCodexAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
|
||
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(() => {
|
||
listener({
|
||
claude: services.claudeAccounts.listAccounts(),
|
||
codex: services.codexAccounts.listAccounts(),
|
||
rateLimits: services.rateLimits.getState()
|
||
})
|
||
})
|
||
}
|
||
|
||
// ─── 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 clampedCols = Math.max(20, Math.min(240, Math.round(cols)))
|
||
const clampedRows = Math.max(8, Math.min(120, Math.round(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'; cols: number; rows: number }> {
|
||
const result = new Map<string, { mode: 'mobile-fit'; cols: number; rows: number }>()
|
||
for (const [ptyId, override] of this.terminalFitOverrides) {
|
||
result.set(ptyId, { mode: override.mode, cols: override.cols, rows: override.rows })
|
||
}
|
||
return result
|
||
}
|
||
|
||
getAllTerminalDrivers(): Map<string, DriverState> {
|
||
return new Map(this.currentDriver)
|
||
}
|
||
|
||
getAllBrowserDrivers(): Map<string, RuntimeBrowserDriverState> {
|
||
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.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 (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
|
||
// 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)
|
||
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 (cur?.kind === 'phone' && this.getAutoRestoreFitMs() != null) {
|
||
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.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||
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): void {
|
||
advertisedUrlWatcher.unbindPty(ptyId)
|
||
// Clean up new mobile state for this PTY
|
||
this.mobileSubscribers.delete(ptyId)
|
||
this.mobileDisplayModes.delete(ptyId)
|
||
this.resizeListeners.delete(ptyId)
|
||
this.lastRendererSizes.delete(ptyId)
|
||
this.recentPtyOutputById.delete(ptyId)
|
||
this.ptyOutputSequenceById.delete(ptyId)
|
||
// 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.disposeHeadlessTerminal(ptyId)
|
||
this.agentDetector?.onExit(ptyId)
|
||
const pty = this.ptysById.get(ptyId)
|
||
if (pty) {
|
||
pty.connected = false
|
||
pty.lastExitCode = exitCode
|
||
this.resolvePtyExitWaiters(pty, ptyId)
|
||
this.pruneDisconnectedPtyTranscript(pty)
|
||
}
|
||
|
||
for (const leaf of this.leaves.values()) {
|
||
if (leaf.ptyId !== ptyId) {
|
||
continue
|
||
}
|
||
this.detachedPreAllocatedLeaves.delete(ptyId)
|
||
leaf.connected = false
|
||
leaf.writable = false
|
||
leaf.lastExitCode = exitCode
|
||
this.resolveExitWaiters(leaf)
|
||
this.failActiveDispatchOnExit(leaf, exitCode)
|
||
}
|
||
}
|
||
|
||
// ─── 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: 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): Promise<void> {
|
||
const inner = this.mobileSubscribers.get(ptyId)
|
||
const sub = inner?.get(clientId)
|
||
if (sub) {
|
||
sub.lastActedAt = Date.now()
|
||
}
|
||
const prev = 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)
|
||
}
|
||
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<boolean> {
|
||
const inner = this.mobileSubscribers.get(ptyId)
|
||
const sub = inner?.get(clientId)
|
||
if (!sub) {
|
||
return false
|
||
}
|
||
sub.viewport = viewport
|
||
sub.lastActedAt = Date.now()
|
||
|
||
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||
if (mode === 'desktop') {
|
||
// Watching at desktop dims — viewport is informational only.
|
||
return true
|
||
}
|
||
// Drive PTY dims by the most-recent-actor (just updated to this client).
|
||
const winner = this.pickMostRecentActor(inner!)
|
||
if (!winner) {
|
||
return false
|
||
}
|
||
const winnerSub = inner!.get(winner.clientId)
|
||
const driveViewport = winnerSub?.viewport ?? viewport
|
||
const clampedCols = Math.max(20, Math.min(240, Math.round(driveViewport.cols)))
|
||
const clampedRows = Math.max(8, Math.min(120, Math.round(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 })
|
||
|
||
await this.enqueueLayout(ptyId, {
|
||
kind: 'phone',
|
||
cols: clampedCols,
|
||
rows: clampedRows,
|
||
ownerClientId: winner.clientId
|
||
})
|
||
return true
|
||
}
|
||
|
||
// Why: remote desktop clients do not have the local `pty:resize` IPC path.
|
||
// Their measured xterm size still has to resize the source PTY so TUIs
|
||
// reflow to the visible client dimensions.
|
||
async updateDesktopViewport(
|
||
ptyId: string,
|
||
viewport: { cols: number; rows: number }
|
||
): Promise<boolean> {
|
||
if (
|
||
this.isResizeSuppressed() ||
|
||
this.getDriver(ptyId).kind === 'mobile' ||
|
||
this.terminalFitOverrides.has(ptyId)
|
||
) {
|
||
return false
|
||
}
|
||
const cols = Math.max(20, Math.min(240, Math.round(viewport.cols)))
|
||
const rows = Math.max(8, Math.min(120, Math.round(viewport.rows)))
|
||
let resized = false
|
||
try {
|
||
resized = this.ptyController?.resize?.(ptyId, cols, rows) ?? false
|
||
} catch {
|
||
return false
|
||
}
|
||
if (!resized) {
|
||
return false
|
||
}
|
||
this.resizeHeadlessTerminal(ptyId, cols, rows)
|
||
this.onExternalPtyResize(ptyId, cols, rows)
|
||
return true
|
||
}
|
||
|
||
// 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. Two cases:
|
||
// 1. Active mobile subscriber: route through applyMobileDisplayMode so
|
||
// the existing 'resized' event reaches the phone.
|
||
// 2. Held with no mobile subscriber (post-indefinite-hold): no inner
|
||
// subscriber to notify; resolve restore target and enqueueLayout
|
||
// directly. applyLayout is the SOLE writer of terminalFitOverrides;
|
||
// the held branch must not duplicate that mutation. See
|
||
// docs/mobile-fit-hold.md.
|
||
async reclaimTerminalForDesktop(ptyId: string): Promise<boolean> {
|
||
if (this.isMobileSubscriberActive(ptyId)) {
|
||
this.setMobileDisplayMode(ptyId, 'desktop')
|
||
await this.applyMobileDisplayMode(ptyId)
|
||
this.setDriver(ptyId, { kind: 'desktop' })
|
||
// 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')
|
||
return true
|
||
}
|
||
const heldOverride = this.terminalFitOverrides.get(ptyId)
|
||
if (heldOverride) {
|
||
const pending = this.pendingRestoreTimers.get(ptyId)
|
||
if (pending) {
|
||
clearTimeout(pending.timer)
|
||
this.pendingRestoreTimers.delete(ptyId)
|
||
}
|
||
// Why: with no subscribers, resolveDesktopRestoreTarget falls through
|
||
// to current PTY size — which is at phone dims (wrong). Prefer the
|
||
// baseline captured on the override at first phone-fit; this is the
|
||
// last desktop geometry the layout machine knew about. Then chain
|
||
// to the standard resolver for the residual fallbacks.
|
||
const fallback = this.resolveDesktopRestoreTarget(ptyId)
|
||
const cols = heldOverride.previousCols ?? fallback.cols
|
||
const rows = heldOverride.previousRows ?? fallback.rows
|
||
await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
|
||
this.setDriver(ptyId, { kind: 'desktop' })
|
||
// Why: a desktop-initiated reclaim is "I'm taking over right now",
|
||
// not a sticky preference. Reset to auto so the next mobile subscribe
|
||
// re-enters phone-fit. (Held-PTY branch may not have an entry, but
|
||
// calling setMobileDisplayMode('auto') is a no-op deletion in that
|
||
// case — safe and idempotent.)
|
||
this.setMobileDisplayMode(ptyId, 'auto')
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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 })
|
||
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<string, { clientId: string; lastActedAt: number }>
|
||
): { 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
|
||
// - 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
|
||
}
|
||
return true
|
||
}
|
||
|
||
private enqueueLayout(ptyId: string, target: PtyLayoutTarget): Promise<ApplyLayoutResult> {
|
||
// 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<ApplyLayoutResult>((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<ApplyLayoutResult> {
|
||
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<ApplyLayoutResult> {
|
||
// 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: emit fit-override-changed only when the *mode* flips. Layouts
|
||
// can change dims without flipping mode (keyboard show/hide while
|
||
// phone), and waking the renderer on every viewport tick is wasteful
|
||
// churn.
|
||
if (modeChanged) {
|
||
// 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' : 'desktop-fit',
|
||
target.cols,
|
||
target.rows
|
||
)
|
||
this.notifyFitOverrideListeners(
|
||
ptyId,
|
||
target.kind === 'phone' ? 'mobile-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<boolean> {
|
||
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||
if (!viewport) {
|
||
return false
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
const clampedCols = Math.max(20, Math.min(240, Math.round(viewport.cols)))
|
||
const clampedRows = Math.max(8, Math.min(120, Math.round(viewport.rows)))
|
||
|
||
// 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,
|
||
lastActedAt: Date.now()
|
||
})
|
||
this.setDriver(ptyId, { kind: 'mobile', clientId })
|
||
if (mode !== 'desktop') {
|
||
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 (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)
|
||
|
||
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.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||
|
||
// 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' })
|
||
}
|
||
}, SOFT_LEAVE_GRACE_MS)
|
||
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
|
||
}
|
||
void this.enqueueLayout(ptyId, {
|
||
kind: 'desktop',
|
||
cols: restoreCols,
|
||
rows: restoreRows
|
||
})
|
||
}, autoRestoreMs)
|
||
|
||
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.
|
||
async applyMobileDisplayMode(ptyId: string): Promise<void> {
|
||
const mode = this.mobileDisplayModes.get(ptyId) ?? 'auto'
|
||
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.
|
||
let anyWasResized = false
|
||
if (inner) {
|
||
for (const sub of inner.values()) {
|
||
if (sub.wasResizedToPhone) {
|
||
anyWasResized = true
|
||
sub.wasResizedToPhone = false
|
||
}
|
||
}
|
||
}
|
||
if (anyWasResized) {
|
||
const restore = this.resolveDesktopRestoreTarget(ptyId)
|
||
await this.enqueueLayout(ptyId, {
|
||
kind: 'desktop',
|
||
cols: restore.cols,
|
||
rows: restore.rows
|
||
})
|
||
} else {
|
||
// No subscriber was fitted — 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)
|
||
return
|
||
}
|
||
}
|
||
// 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
|
||
})
|
||
}
|
||
}
|
||
|
||
// Why: called from the pty:resize IPC handler whenever the desktop renderer
|
||
// resizes a PTY (e.g. via safeFit after window resize, split, or desktop-mode
|
||
// restore). Stores the renderer-reported size so handleMobileSubscribe can use
|
||
// the actual pane geometry instead of a stale PTY size for previousCols.
|
||
// This is a passive geometry report — it does NOT call applyLayout; the
|
||
// PTY is already at the reported size.
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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 {
|
||
let listeners = this.resizeListeners.get(ptyId)
|
||
if (!listeners) {
|
||
listeners = new Set()
|
||
this.resizeListeners.set(ptyId, listeners)
|
||
}
|
||
listeners.add(listener)
|
||
return () => {
|
||
listeners.delete(listener)
|
||
if (listeners.size === 0) {
|
||
this.resizeListeners.delete(ptyId)
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
): Promise<RuntimeTerminalListResult> {
|
||
if (!Number.isInteger(limit) || limit <= 0) {
|
||
throw new Error('invalid_limit')
|
||
}
|
||
const graphEpoch = this.captureReadyGraphEpoch()
|
||
const targetWorktreeId = worktreeSelector
|
||
? (getExplicitWorktreeIdSelector(worktreeSelector) ??
|
||
(await this.resolveWorktreeSelector(worktreeSelector)).id)
|
||
: null
|
||
const worktreesById = await this.getResolvedWorktreeMap()
|
||
this.assertStableReadyGraph(graphEpoch)
|
||
|
||
const resolvedWorktrees = [...worktreesById.values()]
|
||
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
|
||
|
||
const livePtyWorktreeIds = new Set<string>()
|
||
for (const pty of this.ptysById.values()) {
|
||
if (pty.connected) {
|
||
livePtyWorktreeIds.add(pty.worktreeId)
|
||
}
|
||
}
|
||
|
||
const terminals: RuntimeTerminalSummary[] = []
|
||
const ptyIdsFromLeaves = new Set<string>()
|
||
for (const leaf of this.leaves.values()) {
|
||
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
|
||
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 (targetWorktreeId && pty.worktreeId !== targetWorktreeId) {
|
||
continue
|
||
}
|
||
terminals.push(this.buildPtyTerminalSummary(pty, worktreesById))
|
||
}
|
||
|
||
return {
|
||
terminals: terminals.slice(0, limit),
|
||
totalCount: terminals.length,
|
||
truncated: terminals.length > limit
|
||
}
|
||
}
|
||
|
||
// 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<string> {
|
||
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')
|
||
}
|
||
|
||
async showTerminal(handle: string): Promise<RuntimeTerminalShow> {
|
||
const graphEpoch = this.captureReadyGraphEpoch()
|
||
const worktreesById = await this.getResolvedWorktreeMap()
|
||
this.assertStableReadyGraph(graphEpoch)
|
||
const pty = this.getLivePtyForHandle(handle)
|
||
if (pty) {
|
||
return {
|
||
...this.buildPtyTerminalSummary(pty.pty, worktreesById),
|
||
paneRuntimeId: -1,
|
||
ptyId: pty.pty.ptyId,
|
||
rendererGraphEpoch: this.rendererGraphEpoch
|
||
}
|
||
}
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
const summary = this.buildTerminalSummary(leaf, worktreesById)
|
||
return {
|
||
...summary,
|
||
paneRuntimeId: leaf.paneRuntimeId,
|
||
ptyId: leaf.ptyId,
|
||
rendererGraphEpoch: this.rendererGraphEpoch
|
||
}
|
||
}
|
||
|
||
async readTerminal(
|
||
handle: string,
|
||
opts: { cursor?: number; limit?: number } = {}
|
||
): Promise<RuntimeTerminalRead> {
|
||
const pty = this.getLivePtyForHandle(handle)
|
||
if (pty) {
|
||
return this.readPtyTerminal(handle, pty.pty, opts)
|
||
}
|
||
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
const read = readTerminalTail({
|
||
handle,
|
||
status: getTerminalState(leaf),
|
||
completedLines: leaf.tailBuffer,
|
||
partialLine: leaf.tailPartialLine,
|
||
completedLineCount: leaf.tailLinesTotal,
|
||
bufferTruncated: leaf.tailTruncated,
|
||
cursor: opts.cursor,
|
||
limit: opts.limit
|
||
})
|
||
return read
|
||
}
|
||
|
||
async sendTerminal(
|
||
handle: string,
|
||
action: {
|
||
text?: string
|
||
enter?: boolean
|
||
interrupt?: boolean
|
||
}
|
||
): Promise<RuntimeTerminalSend> {
|
||
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 this.writeTerminalAction(pty.pty.ptyId, action, payload)
|
||
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 this.writeTerminalAction(leaf.ptyId, action, payload)
|
||
|
||
return {
|
||
handle,
|
||
accepted: true,
|
||
bytesWritten: Buffer.byteLength(payload, 'utf8')
|
||
}
|
||
}
|
||
|
||
private async writeTerminalAction(
|
||
ptyId: string,
|
||
action: { text?: string; enter?: boolean; interrupt?: boolean },
|
||
payload: string
|
||
): Promise<void> {
|
||
// Why: TUI apps (Claude Code, etc.) treat a single large write as a paste
|
||
// event. Keep Enter/interrupt as a second write for both visible and
|
||
// background PTYs so CLI automation behaves the same either way.
|
||
const hasText = typeof action.text === 'string' && action.text.length > 0
|
||
const hasSuffix = action.enter || action.interrupt
|
||
if (hasText && hasSuffix) {
|
||
const textWrote = this.ptyController?.write(ptyId, action.text!) ?? false
|
||
if (!textWrote) {
|
||
throw new Error('terminal_not_writable')
|
||
}
|
||
const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '')
|
||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||
const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false
|
||
if (!suffixWrote) {
|
||
throw new Error('terminal_not_writable')
|
||
}
|
||
return
|
||
}
|
||
|
||
const wrote = this.ptyController?.write(ptyId, payload) ?? false
|
||
if (!wrote) {
|
||
throw new Error('terminal_not_writable')
|
||
}
|
||
}
|
||
|
||
async waitForTerminal(
|
||
handle: string,
|
||
options?: {
|
||
condition?: RuntimeTerminalWaitCondition
|
||
timeoutMs?: number
|
||
signal?: AbortSignal
|
||
}
|
||
): Promise<RuntimeTerminalWait> {
|
||
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' ||
|
||
isCodexReadyPromptPreview(ptyWaitText))
|
||
) {
|
||
return buildPtyTerminalWaitResult(handle, condition, pty.pty)
|
||
}
|
||
return await new Promise<RuntimeTerminalWait>((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' ||
|
||
isCodexReadyPromptPreview(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') ||
|
||
isCodexReadyPromptPreview(leafWaitText)
|
||
) {
|
||
return buildTerminalWaitResult(handle, condition, leaf)
|
||
}
|
||
}
|
||
|
||
return await new Promise<RuntimeTerminalWait>((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 Codex's 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') ||
|
||
isCodexReadyPromptPreview(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 resolvedWorktrees = await this.listResolvedWorktrees()
|
||
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
|
||
const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo]))
|
||
const summaries = new Map<string, RuntimeWorktreePsSummary>()
|
||
|
||
// 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 (repo?.path && branch && ghCache) {
|
||
const prCacheKey = `${repo.path}::${branch}`
|
||
const cached = ghCache.pr[prCacheKey]
|
||
if (cached?.data) {
|
||
linkedPR = { number: cached.data.number, state: cached.data.state }
|
||
}
|
||
}
|
||
if (!linkedPR && meta?.linkedPR != null) {
|
||
linkedPR = { number: meta.linkedPR, state: 'unknown' }
|
||
}
|
||
summaries.set(worktree.id, {
|
||
worktreeId: worktree.id,
|
||
repoId: worktree.repoId,
|
||
repo: repo?.displayName ?? worktree.repoId,
|
||
path: worktree.path,
|
||
branch: worktree.branch,
|
||
parentWorktreeId: worktree.parentWorktreeId,
|
||
childWorktreeIds: worktree.childWorktreeIds,
|
||
displayName: worktree.displayName,
|
||
linkedIssue: worktree.linkedIssue,
|
||
linkedPR,
|
||
isPinned: meta?.isPinned ?? false,
|
||
unread: meta?.isUnread ?? false,
|
||
liveTerminalCount: 0,
|
||
hasAttachedPty: false,
|
||
lastOutputAt: null,
|
||
preview: '',
|
||
status: 'inactive'
|
||
})
|
||
}
|
||
|
||
const countedPtyIds = new Set<string>()
|
||
for (const leaf of this.leaves.values()) {
|
||
const summary = this.getSummaryForRuntimeWorktreeId(
|
||
summaries,
|
||
resolvedWorktrees,
|
||
leaf.worktreeId
|
||
)
|
||
if (!summary) {
|
||
continue
|
||
}
|
||
if (leaf.ptyId) {
|
||
countedPtyIds.add(leaf.ptyId)
|
||
}
|
||
const previousLastOutputAt = summary.lastOutputAt
|
||
summary.liveTerminalCount += 1
|
||
summary.hasAttachedPty = summary.hasAttachedPty || leaf.connected
|
||
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)) {
|
||
continue
|
||
}
|
||
const summary = this.getSummaryForRuntimeWorktreeId(
|
||
summaries,
|
||
resolvedWorktrees,
|
||
pty.worktreeId
|
||
)
|
||
if (!summary) {
|
||
continue
|
||
}
|
||
const previousLastOutputAt = summary.lastOutputAt
|
||
summary.liveTerminalCount += 1
|
||
summary.hasAttachedPty = true
|
||
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt)
|
||
summary.status = mergeWorktreeStatus(summary.status, 'active')
|
||
if (
|
||
pty.preview &&
|
||
(summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1))
|
||
) {
|
||
summary.preview = pty.preview
|
||
}
|
||
}
|
||
|
||
const session = this.store?.getWorkspaceSession?.()
|
||
for (const [worktreeId, tabs] of Object.entries(session?.tabsByWorktree ?? {})) {
|
||
if (tabs.length === 0) {
|
||
continue
|
||
}
|
||
const summary = this.getSummaryForRuntimeWorktreeId(summaries, resolvedWorktrees, worktreeId)
|
||
if (!summary) {
|
||
continue
|
||
}
|
||
// Why: desktop can show terminal tabs that are not mounted as renderer
|
||
// leaves and are not currently visible in the PTY provider list. Mobile
|
||
// still needs those worktrees to show as terminal-bearing entries.
|
||
summary.liveTerminalCount = Math.max(summary.liveTerminalCount, tabs.length)
|
||
summary.hasAttachedPty = summary.hasAttachedPty || tabs.some((tab) => tab.ptyId !== null)
|
||
for (const tab of tabs) {
|
||
summary.status = mergeWorktreeStatus(
|
||
summary.status,
|
||
getSavedTabWorktreeStatus(tab.title, tab.ptyId !== null)
|
||
)
|
||
}
|
||
}
|
||
|
||
const sorted = [...summaries.values()].sort(compareWorktreePs)
|
||
return {
|
||
worktrees: sorted.slice(0, limit),
|
||
totalCount: sorted.length,
|
||
truncated: sorted.length > limit
|
||
}
|
||
}
|
||
|
||
listRepos(): Repo[] {
|
||
return this.store?.getRepos() ?? []
|
||
}
|
||
|
||
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'): Promise<Repo> {
|
||
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('Repo 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) => runtimePathsEqual(repo.path, path))
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
|
||
const repo: Repo = {
|
||
id: randomUUID(),
|
||
path,
|
||
displayName: getRepoName(path),
|
||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||
addedAt: Date.now(),
|
||
kind,
|
||
...(kind === 'git'
|
||
? {
|
||
externalWorktreeVisibility: 'hide' as const,
|
||
externalWorktreeVisibilityLegacy: false
|
||
}
|
||
: {})
|
||
}
|
||
this.store.addRepo(repo)
|
||
this.invalidateResolvedWorktreeCache()
|
||
this.notifier?.reposChanged()
|
||
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 {
|
||
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 repo: Repo = {
|
||
id: randomUUID(),
|
||
path: targetPath,
|
||
displayName: trimmedName,
|
||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||
addedAt: Date.now(),
|
||
kind: repoKind,
|
||
...(repoKind === 'git'
|
||
? {
|
||
externalWorktreeVisibility: 'hide' as const,
|
||
externalWorktreeVisibilityLegacy: false
|
||
}
|
||
: {})
|
||
}
|
||
this.store.addRepo(repo)
|
||
invalidateAuthorizedRootsCache()
|
||
this.invalidateResolvedWorktreeCache()
|
||
this.notifier?.reposChanged()
|
||
return { repo: this.store.getRepo(repo.id) ?? repo }
|
||
}
|
||
|
||
async cloneRepo(url: string, destination: string): Promise<Repo> {
|
||
if (!this.store) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
const trimmedUrl = url.trim()
|
||
const trimmedDestination = destination.trim()
|
||
const repoName = basename(trimmedUrl.replace(/\.git\/?$/, ''))
|
||
if (!repoName) {
|
||
throw new Error('Could not determine repository name from URL')
|
||
}
|
||
if (!trimmedDestination) {
|
||
throw new Error('Clone destination is required')
|
||
}
|
||
if (!isAbsolute(trimmedDestination)) {
|
||
throw new Error('Clone destination must be an absolute path')
|
||
}
|
||
await mkdir(trimmedDestination, { recursive: true })
|
||
const clonePath = join(trimmedDestination, repoName)
|
||
await new Promise<void>((resolve, reject) => {
|
||
const proc = wslAwareSpawn('git', ['clone', '--progress', '--', trimmedUrl, clonePath], {
|
||
cwd: trimmedDestination,
|
||
stdio: ['ignore', 'ignore', 'pipe']
|
||
})
|
||
let stderrTail = ''
|
||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||
stderrTail = (stderrTail + chunk.toString()).slice(-4096)
|
||
})
|
||
proc.on('error', (error) => reject(new Error(`Clone failed: ${error.message}`)))
|
||
proc.on('close', (code, signal) => {
|
||
if (signal === 'SIGTERM') {
|
||
reject(new Error('Clone aborted'))
|
||
} else if (code === 0) {
|
||
resolve()
|
||
} else {
|
||
const lastLine = stderrTail.trim().split('\n').pop() ?? 'unknown error'
|
||
reject(new Error(`Clone failed: ${lastLine}`))
|
||
}
|
||
})
|
||
})
|
||
|
||
const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, clonePath))
|
||
if (existing) {
|
||
if (isFolderRepo(existing)) {
|
||
const updated = this.store.updateRepo(existing.id, { kind: 'git' })
|
||
if (updated) {
|
||
this.notifier?.reposChanged()
|
||
return updated
|
||
}
|
||
}
|
||
return existing
|
||
}
|
||
|
||
const repo: Repo = {
|
||
id: randomUUID(),
|
||
path: clonePath,
|
||
displayName: getRepoName(clonePath),
|
||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||
addedAt: Date.now(),
|
||
kind: 'git',
|
||
externalWorktreeVisibility: 'hide',
|
||
externalWorktreeVisibilityLegacy: false
|
||
}
|
||
this.store.addRepo(repo)
|
||
invalidateAuthorizedRootsCache()
|
||
this.invalidateResolvedWorktreeCache()
|
||
this.notifier?.reposChanged()
|
||
return this.store.getRepo(repo.id) ?? repo
|
||
}
|
||
|
||
async showRepo(repoSelector: string): Promise<Repo> {
|
||
return await this.resolveRepoSelector(repoSelector)
|
||
}
|
||
|
||
async setRepoBaseRef(repoSelector: string, baseRef: string): Promise<Repo> {
|
||
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.notifier?.reposChanged()
|
||
return updated
|
||
}
|
||
|
||
async updateRepo(
|
||
repoSelector: string,
|
||
updates: Partial<
|
||
Pick<
|
||
Repo,
|
||
| 'displayName'
|
||
| 'badgeColor'
|
||
| 'repoIcon'
|
||
| 'hookSettings'
|
||
| 'worktreeBaseRef'
|
||
| 'kind'
|
||
| 'symlinkPaths'
|
||
| 'issueSourcePreference'
|
||
| 'externalWorktreeVisibility'
|
||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||
>
|
||
>
|
||
): Promise<Repo> {
|
||
if (!this.store) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
const updated = this.store.updateRepo(repo.id, omitUndefinedProperties(updates))
|
||
if (!updated) {
|
||
throw new Error('repo_not_found')
|
||
}
|
||
this.invalidateResolvedWorktreeCache()
|
||
this.notifier?.reposChanged()
|
||
return updated
|
||
}
|
||
|
||
async removeRepo(repoSelector: string): Promise<{ removed: true }> {
|
||
if (!this.store?.removeRepo) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
this.store.removeRepo(repo.id)
|
||
this.invalidateResolvedWorktreeCache()
|
||
invalidateAuthorizedRootsCache()
|
||
this.notifier?.reposChanged()
|
||
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.notifier?.reposChanged()
|
||
return { status: 'applied' }
|
||
}
|
||
|
||
async searchRepoRefs(
|
||
repoSelector: string,
|
||
query: string,
|
||
limit = DEFAULT_REPO_SEARCH_REFS_LIMIT
|
||
): Promise<RuntimeRepoSearchRefs> {
|
||
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<BaseRefSearchResult[]> {
|
||
const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null
|
||
if (!provider) {
|
||
return []
|
||
}
|
||
const normalizedQuery = normalizeRefSearchQuery(query)
|
||
if (!normalizedQuery) {
|
||
return []
|
||
}
|
||
try {
|
||
const [result, remotesResult] = await Promise.all([
|
||
provider.exec(buildSearchBaseRefsArgv(normalizedQuery), repo.path),
|
||
provider.exec(['remote'], repo.path).catch(() => ({ stdout: '' }))
|
||
])
|
||
const remotes = remotesResult.stdout
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
return parseAndFilterSearchRefDetails(result.stdout, 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 }
|
||
}
|
||
|
||
async getRepoSlug(repoSelector: string): Promise<{ owner: string; repo: string } | null> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getRepoSlug(repo.path, repo.connectionId ?? null)
|
||
}
|
||
|
||
async listRepoWorkItems(
|
||
repoSelector: string,
|
||
limit?: number,
|
||
query?: string,
|
||
before?: string
|
||
): Promise<Awaited<ReturnType<typeof listWorkItems>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return listWorkItems(
|
||
repo.path,
|
||
limit,
|
||
query,
|
||
before,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null
|
||
)
|
||
}
|
||
|
||
async listRepoIssues(
|
||
repoSelector: string,
|
||
limit?: number
|
||
): Promise<Awaited<ReturnType<typeof listGitHubIssues>>['items']> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
const result = await listGitHubIssues(
|
||
repo.path,
|
||
limit,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null
|
||
)
|
||
return result.items
|
||
}
|
||
|
||
async getRepoWorkItem(
|
||
repoSelector: string,
|
||
number: number,
|
||
type?: 'issue' | 'pr'
|
||
): Promise<Awaited<ReturnType<typeof getWorkItem>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getWorkItem(repo.path, number, type, repo.connectionId ?? null)
|
||
}
|
||
|
||
async getRepoWorkItemByOwnerRepo(
|
||
repoSelector: string,
|
||
ownerRepo: { owner: string; repo: string },
|
||
number: number,
|
||
type: 'issue' | 'pr'
|
||
): Promise<Awaited<ReturnType<typeof getWorkItemByOwnerRepo>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getWorkItemByOwnerRepo(repo.path, ownerRepo, number, type, repo.connectionId ?? null)
|
||
}
|
||
|
||
async getRepoWorkItemDetails(
|
||
repoSelector: string,
|
||
number: number,
|
||
type?: 'issue' | 'pr'
|
||
): Promise<Awaited<ReturnType<typeof getWorkItemDetails>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getWorkItemDetails(repo.path, number, type, repo.connectionId ?? null)
|
||
}
|
||
|
||
async countRepoWorkItems(repoSelector: string, query?: string): Promise<number> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return countWorkItems(repo.path, query, repo.issueSourcePreference, repo.connectionId ?? null)
|
||
}
|
||
|
||
async listRepoLabels(repoSelector: string): Promise<Awaited<ReturnType<typeof listLabels>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return listLabels(repo.path, repo.issueSourcePreference, repo.connectionId ?? null)
|
||
}
|
||
|
||
async listRepoAssignableUsers(
|
||
repoSelector: string
|
||
): Promise<Awaited<ReturnType<typeof listAssignableUsers>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return listAssignableUsers(repo.path, repo.issueSourcePreference, repo.connectionId ?? null)
|
||
}
|
||
|
||
getGitHubRateLimit(options?: {
|
||
force?: boolean
|
||
}): Promise<Awaited<ReturnType<typeof getRateLimit>>> {
|
||
return getRateLimit(options)
|
||
}
|
||
|
||
async getRepoPRForBranch(
|
||
repoSelector: string,
|
||
branch: string,
|
||
linkedPRNumber?: number | null,
|
||
fallbackPRNumber?: number | null
|
||
): Promise<Awaited<ReturnType<typeof getPRForBranch>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getPRForBranch(
|
||
repo.path,
|
||
branch,
|
||
linkedPRNumber ?? null,
|
||
repo.connectionId ?? null,
|
||
linkedPRNumber == null ? (fallbackPRNumber ?? null) : null
|
||
)
|
||
}
|
||
|
||
async getHostedReviewForBranch(args: {
|
||
repoSelector: string
|
||
branch: string
|
||
linkedGitHubPR?: number | null
|
||
fallbackGitHubPR?: number | null
|
||
linkedGitLabMR?: number | null
|
||
linkedBitbucketPR?: number | null
|
||
linkedAzureDevOpsPR?: number | null
|
||
linkedGiteaPR?: number | null
|
||
}): Promise<HostedReviewInfo | null> {
|
||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||
const review = await getHostedReviewForBranchFromRepo({
|
||
repoPath: repo.path,
|
||
connectionId: repo.connectionId ?? null,
|
||
branch: args.branch,
|
||
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
|
||
})
|
||
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<HostedReviewCreationEligibilityArgs, 'repoPath'> & {
|
||
repoSelector: string
|
||
worktreeSelector?: string
|
||
}
|
||
): Promise<HostedReviewCreationEligibility> {
|
||
const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
|
||
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
|
||
})
|
||
}
|
||
|
||
async createHostedReview(
|
||
args: CreateHostedReviewInput & { repoSelector: string; worktreeSelector?: string }
|
||
): Promise<CreateHostedReviewResult> {
|
||
const { repo, repoPath } = await this.resolveHostedReviewTarget(args)
|
||
const result = await createHostedReviewFromRepo(
|
||
repoPath,
|
||
{
|
||
provider: args.provider,
|
||
base: args.base,
|
||
head: args.head,
|
||
title: args.title,
|
||
body: args.body,
|
||
draft: args.draft
|
||
},
|
||
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<Awaited<ReturnType<typeof listGitLabWorkItems>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return listGitLabWorkItems(
|
||
repo.path,
|
||
state ?? 'opened',
|
||
page ?? 1,
|
||
perPage ?? 20,
|
||
repo.issueSourcePreference,
|
||
query,
|
||
repo.connectionId ?? null
|
||
)
|
||
}
|
||
|
||
async listGitLabRepoMRs(
|
||
repoSelector: string,
|
||
state?: MRListState,
|
||
page?: number,
|
||
perPage?: number,
|
||
query?: string
|
||
): Promise<Awaited<ReturnType<typeof listGitLabMergeRequests>>> {
|
||
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
|
||
)
|
||
}
|
||
|
||
async listGitLabRepoIssues(
|
||
repoSelector: string,
|
||
state?: GitLabIssueListState,
|
||
assignee?: string,
|
||
limit?: number
|
||
): Promise<{
|
||
items: GitLabWorkItem[]
|
||
error?: Awaited<ReturnType<typeof listGitLabIssues>>['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
|
||
)
|
||
// 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<Awaited<ReturnType<typeof listGitLabTodos>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return listGitLabTodos(repo.path, repo.connectionId ?? null)
|
||
}
|
||
|
||
async createGitLabRepoIssue(
|
||
repoSelector: string,
|
||
title: string,
|
||
body: string
|
||
): Promise<Awaited<ReturnType<typeof createGitLabIssue>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return createGitLabIssue(
|
||
repo.path,
|
||
title,
|
||
body,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null
|
||
)
|
||
}
|
||
|
||
async updateGitLabRepoIssue(
|
||
repoSelector: string,
|
||
number: number,
|
||
updates: GitLabIssueUpdate,
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof updateGitLabIssue>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return updateGitLabIssue(
|
||
repo.path,
|
||
number,
|
||
updates,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async addGitLabRepoIssueComment(
|
||
repoSelector: string,
|
||
number: number,
|
||
body: string,
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof addGitLabIssueComment>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return addGitLabIssueComment(
|
||
repo.path,
|
||
number,
|
||
body,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async addGitLabRepoMRComment(
|
||
repoSelector: string,
|
||
iid: number,
|
||
body: string,
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof addGitLabMRComment>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return addGitLabMRComment(
|
||
repo.path,
|
||
iid,
|
||
body,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async mergeGitLabRepoMR(
|
||
repoSelector: string,
|
||
iid: number,
|
||
method?: 'merge' | 'squash' | 'rebase',
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof mergeGitLabMR>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return mergeGitLabMR(
|
||
repo.path,
|
||
iid,
|
||
method ?? 'merge',
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async updateGitLabRepoMRState(
|
||
repoSelector: string,
|
||
iid: number,
|
||
state: 'opened' | 'closed',
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof closeGitLabMR>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return state === 'closed'
|
||
? closeGitLabMR(
|
||
repo.path,
|
||
iid,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
: reopenGitLabMR(
|
||
repo.path,
|
||
iid,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async updateGitLabRepoMR(
|
||
repoSelector: string,
|
||
iid: number,
|
||
updates: { title?: string; body?: string; addLabels?: string[]; removeLabels?: string[] },
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof updateGitLabMR>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return updateGitLabMR(
|
||
repo.path,
|
||
iid,
|
||
updates,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async getGitLabRepoWorkItemDetails(
|
||
repoSelector: string,
|
||
iid: number,
|
||
type: 'issue' | 'mr',
|
||
projectRef?: GitLabProjectRef | null
|
||
): Promise<Awaited<ReturnType<typeof getGitLabWorkItemDetails>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getGitLabWorkItemDetails(
|
||
repo.path,
|
||
iid,
|
||
type,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null,
|
||
projectRef
|
||
)
|
||
}
|
||
|
||
async getGitLabRepoWorkItemByPath(
|
||
repoSelector: string,
|
||
projectRef: GitLabProjectRef,
|
||
iid: number,
|
||
type: 'issue' | 'mr'
|
||
): Promise<Awaited<ReturnType<typeof getGitLabWorkItemByProjectRef>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
const result = await getGitLabWorkItemByProjectRef(
|
||
repo.path,
|
||
projectRef,
|
||
iid,
|
||
type,
|
||
repo.connectionId ?? null
|
||
)
|
||
// 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<Awaited<ReturnType<typeof getIssue>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getIssue(repo.path, number, repo.connectionId ?? null)
|
||
}
|
||
|
||
async getRepoPRChecks(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
headSha?: string,
|
||
prRepo?: GitHubOwnerRepo | null,
|
||
options?: { noCache?: boolean }
|
||
): Promise<Awaited<ReturnType<typeof getPRChecks>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getPRChecks(
|
||
repo.path,
|
||
prNumber,
|
||
headSha,
|
||
prRepo ?? null,
|
||
options,
|
||
repo.connectionId ?? null
|
||
)
|
||
}
|
||
|
||
async rerunRepoPRChecks(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
options?: { headSha?: string; failedOnly?: boolean }
|
||
): Promise<Awaited<ReturnType<typeof rerunPRChecks>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return rerunPRChecks(repo.path, prNumber, options, repo.connectionId ?? null)
|
||
}
|
||
|
||
async getRepoPRCheckDetails(
|
||
repoSelector: string,
|
||
args: {
|
||
checkRunId?: number
|
||
workflowRunId?: number
|
||
checkName?: string
|
||
url?: string | null
|
||
prRepo?: GitHubOwnerRepo | null
|
||
}
|
||
): Promise<Awaited<ReturnType<typeof getPRCheckDetails>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_check_details')
|
||
return getPRCheckDetails(repo.path, { ...args, prRepo: args.prRepo ?? null })
|
||
}
|
||
|
||
async getRepoPRComments(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
prRepo?: GitHubOwnerRepo | null,
|
||
options?: { noCache?: boolean }
|
||
): Promise<Awaited<ReturnType<typeof getPRComments>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getPRComments(
|
||
repo.path,
|
||
prNumber,
|
||
{ ...options, prRepo: prRepo ?? null },
|
||
repo.connectionId ?? null
|
||
)
|
||
}
|
||
|
||
async getRepoPRFileContents(
|
||
repoSelector: string,
|
||
args: {
|
||
prNumber: number
|
||
path: string
|
||
oldPath?: string
|
||
status: GitHubPRFile['status']
|
||
headSha: string
|
||
baseSha: string
|
||
}
|
||
): Promise<Awaited<ReturnType<typeof getPRFileContents>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return getPRFileContents({
|
||
repoPath: repo.path,
|
||
connectionId: repo.connectionId ?? null,
|
||
...args
|
||
})
|
||
}
|
||
|
||
async resolveRepoReviewThread(
|
||
repoSelector: string,
|
||
threadId: string,
|
||
resolve: boolean
|
||
): Promise<Awaited<ReturnType<typeof resolveReviewThread>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return resolveReviewThread(repo.path, threadId, resolve, repo.connectionId ?? null)
|
||
}
|
||
|
||
async setRepoPRFileViewed(
|
||
repoSelector: string,
|
||
args: {
|
||
pullRequestId: string
|
||
path: string
|
||
viewed: boolean
|
||
}
|
||
): Promise<Awaited<ReturnType<typeof setPRFileViewed>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return setPRFileViewed({
|
||
repoPath: repo.path,
|
||
connectionId: repo.connectionId ?? null,
|
||
...args
|
||
})
|
||
}
|
||
|
||
async updateRepoPRTitle(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
title: string,
|
||
prRepo?: GitHubOwnerRepo | null
|
||
): Promise<Awaited<ReturnType<typeof updatePRTitle>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return updatePRTitle(repo.path, prNumber, title, repo.connectionId ?? null, prRepo ?? null)
|
||
}
|
||
|
||
async updateRepoPRDetails(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
updates: { title?: string; body?: string },
|
||
prRepo?: GitHubOwnerRepo | null
|
||
): Promise<Awaited<ReturnType<typeof updatePRDetails>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return updatePRDetails(repo.path, prNumber, updates, repo.connectionId ?? null, prRepo ?? null)
|
||
}
|
||
|
||
async mergeRepoPR(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
method?: 'merge' | 'squash' | 'rebase',
|
||
prRepo?: GitHubOwnerRepo | null
|
||
): Promise<Awaited<ReturnType<typeof mergePR>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null)
|
||
}
|
||
|
||
async updateRepoPRState(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
updates: GitHubPullRequestStateUpdate
|
||
): Promise<Awaited<ReturnType<typeof updatePRState>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return updatePRState(repo.path, prNumber, updates, repo.connectionId ?? null)
|
||
}
|
||
|
||
async requestRepoPRReviewers(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
reviewers: string[]
|
||
): Promise<Awaited<ReturnType<typeof requestPRReviewers>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return requestPRReviewers(repo.path, prNumber, reviewers, repo.connectionId ?? null)
|
||
}
|
||
|
||
async removeRepoPRReviewers(
|
||
repoSelector: string,
|
||
prNumber: number,
|
||
reviewers: string[]
|
||
): Promise<Awaited<ReturnType<typeof removePRReviewers>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
this.assertHostIntegrationRepoIsLocal(repo, 'repo_pr_reviewers')
|
||
return removePRReviewers(repo.path, prNumber, reviewers)
|
||
}
|
||
|
||
async createRepoIssue(
|
||
repoSelector: string,
|
||
title: string,
|
||
body: string
|
||
): Promise<Awaited<ReturnType<typeof createIssue>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return createIssue(
|
||
repo.path,
|
||
title,
|
||
body,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null
|
||
)
|
||
}
|
||
|
||
async updateRepoIssue(
|
||
repoSelector: string,
|
||
number: number,
|
||
updates: GitHubIssueUpdate
|
||
): Promise<Awaited<ReturnType<typeof updateIssue>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return updateIssue(repo.path, number, updates, repo.connectionId ?? null)
|
||
}
|
||
|
||
async addRepoIssueComment(
|
||
repoSelector: string,
|
||
number: number,
|
||
body: string
|
||
): Promise<Awaited<ReturnType<typeof addIssueComment>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return addIssueComment(repo.path, number, body, repo.connectionId ?? null)
|
||
}
|
||
|
||
async addRepoPRReviewComment(
|
||
repoSelector: string,
|
||
args: Omit<GitHubPRReviewCommentInput, 'repoPath'>
|
||
): Promise<Awaited<ReturnType<typeof addPRReviewComment>>> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
return addPRReviewComment({
|
||
repoPath: repo.path,
|
||
connectionId: repo.connectionId ?? null,
|
||
...args
|
||
})
|
||
}
|
||
|
||
async addRepoPRReviewCommentReply(
|
||
repoSelector: string,
|
||
args: {
|
||
prNumber: number
|
||
commentId: number
|
||
body: string
|
||
threadId?: string
|
||
path?: string
|
||
line?: number
|
||
}
|
||
): Promise<Awaited<ReturnType<typeof addPRReviewCommentReply>>> {
|
||
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
|
||
)
|
||
}
|
||
|
||
async listGitHubProjects(): Promise<Awaited<ReturnType<typeof listAccessibleProjects>>> {
|
||
return listAccessibleProjects()
|
||
}
|
||
|
||
async listGitHubLabelsBySlug(
|
||
args: ListLabelsBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof listLabelsBySlug>>> {
|
||
return listLabelsBySlug(args)
|
||
}
|
||
|
||
async listGitHubAssignableUsersBySlug(
|
||
args: ListAssignableUsersBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof listAssignableUsersBySlug>>> {
|
||
return listAssignableUsersBySlug(args)
|
||
}
|
||
|
||
async listGitHubIssueTypesBySlug(
|
||
args: ListIssueTypesBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof listIssueTypesBySlug>>> {
|
||
return listIssueTypesBySlug(args)
|
||
}
|
||
|
||
async resolveGitHubProjectRef(
|
||
args: ResolveProjectRefArgs
|
||
): Promise<Awaited<ReturnType<typeof resolveProjectRef>>> {
|
||
return resolveProjectRef(args)
|
||
}
|
||
|
||
async listGitHubProjectViews(
|
||
args: ListProjectViewsArgs
|
||
): Promise<Awaited<ReturnType<typeof listProjectViews>>> {
|
||
return listProjectViews(args)
|
||
}
|
||
|
||
async getGitHubProjectViewTable(
|
||
args: GetProjectViewTableArgs
|
||
): Promise<Awaited<ReturnType<typeof getProjectViewTable>>> {
|
||
return getProjectViewTable(args)
|
||
}
|
||
|
||
async getGitHubProjectWorkItemDetailsBySlug(
|
||
args: ProjectWorkItemDetailsBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof getWorkItemDetailsBySlug>>> {
|
||
return getWorkItemDetailsBySlug(args)
|
||
}
|
||
|
||
async updateGitHubProjectItemField(
|
||
args: UpdateProjectItemFieldArgs
|
||
): Promise<Awaited<ReturnType<typeof updateProjectItemFieldValue>>> {
|
||
return updateProjectItemFieldValue(args)
|
||
}
|
||
|
||
async clearGitHubProjectItemField(
|
||
args: ClearProjectItemFieldArgs
|
||
): Promise<Awaited<ReturnType<typeof clearProjectItemFieldValue>>> {
|
||
return clearProjectItemFieldValue(args)
|
||
}
|
||
|
||
async updateGitHubIssueBySlug(
|
||
args: UpdateIssueBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof updateIssueBySlug>>> {
|
||
return updateIssueBySlug(args)
|
||
}
|
||
|
||
async updateGitHubPullRequestBySlug(
|
||
args: UpdatePullRequestBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof updatePullRequestBySlug>>> {
|
||
return updatePullRequestBySlug(args)
|
||
}
|
||
|
||
async updateGitHubIssueTypeBySlug(
|
||
args: UpdateIssueTypeBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof updateIssueTypeBySlug>>> {
|
||
return updateIssueTypeBySlug(args)
|
||
}
|
||
|
||
async addGitHubIssueCommentBySlug(
|
||
args: AddIssueCommentBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof addIssueCommentBySlug>>> {
|
||
return addIssueCommentBySlug(args)
|
||
}
|
||
|
||
async updateGitHubIssueCommentBySlug(
|
||
args: UpdateIssueCommentBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof updateIssueCommentBySlug>>> {
|
||
return updateIssueCommentBySlug(args)
|
||
}
|
||
|
||
async deleteGitHubIssueCommentBySlug(
|
||
args: DeleteIssueCommentBySlugArgs
|
||
): Promise<Awaited<ReturnType<typeof deleteIssueCommentBySlug>>> {
|
||
return deleteIssueCommentBySlug(args)
|
||
}
|
||
|
||
private getSetupHookTrustPayload(
|
||
repo: Repo,
|
||
setupScript: string | undefined
|
||
): { contentHash: string; scriptContent: string } | undefined {
|
||
const scriptContent = setupScript?.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, hooks?.scripts?.setup)
|
||
}
|
||
} 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, sharedHooks?.scripts?.setup)
|
||
}
|
||
}
|
||
|
||
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 }
|
||
}
|
||
const { parse } = await import('yaml')
|
||
const parsed = parse(result.content)
|
||
return { hasHooks: true, hooks: parsed, 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<string | null> {
|
||
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<string | null> {
|
||
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
|
||
): Promise<void> {
|
||
const gitignorePath = joinWorktreeRelativePath(repoPath, '.gitignore')
|
||
try {
|
||
const result = await fsProvider.readFile(gitignorePath)
|
||
if (result.isBinary || /^\.orca\/?$/m.test(result.content)) {
|
||
return
|
||
}
|
||
const separator = result.content.endsWith('\n') ? '' : '\n'
|
||
await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`)
|
||
} catch {
|
||
try {
|
||
await fsProvider.writeFile(gitignorePath, '.orca\n')
|
||
} catch (error) {
|
||
console.warn('[runtime] Could not update remote .gitignore to exclude .orca', error)
|
||
}
|
||
}
|
||
}
|
||
|
||
async listManagedWorktrees(
|
||
repoSelector?: string,
|
||
limit = DEFAULT_WORKTREE_LIST_LIMIT
|
||
): Promise<RuntimeWorktreeListResult> {
|
||
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 worktrees = resolved.filter((worktree) => {
|
||
if (repoId && worktree.repoId !== repoId) {
|
||
return false
|
||
}
|
||
return this.isRuntimeWorktreeVisible(worktree)
|
||
})
|
||
return {
|
||
worktrees: worktrees.slice(0, limit),
|
||
totalCount: worktrees.length,
|
||
truncated: worktrees.length > limit
|
||
}
|
||
}
|
||
|
||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||
const repo = await this.resolveRepoSelector(repoSelector)
|
||
if (isFolderRepo(repo)) {
|
||
const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo)
|
||
return {
|
||
repoId: repo.id,
|
||
authoritative: true,
|
||
source: 'git',
|
||
worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
|
||
}
|
||
}
|
||
let scan: RuntimeWorktreeScanResult
|
||
try {
|
||
scan = await this.listRepoWorktreesForResolution(repo)
|
||
} catch {
|
||
scan = { ok: false, worktrees: [] }
|
||
}
|
||
if (scan.ok) {
|
||
this.pruneLineageForMissingRepoWorktrees(repo, scan.worktrees)
|
||
}
|
||
const detected = scan.worktrees.map((gitWorktree) => {
|
||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||
const meta = this.store?.getWorktreeMeta(worktreeId)
|
||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||
const detectedWorktree = this.toRuntimeDetectedWorktree(repo, worktree)
|
||
if (scan.ok) {
|
||
return detectedWorktree
|
||
}
|
||
return {
|
||
...detectedWorktree,
|
||
visible: true,
|
||
ownership: detectedWorktree.ownership === 'orca-managed' ? 'orca-managed' : 'unknown-legacy'
|
||
} satisfies DetectedWorktree
|
||
})
|
||
return {
|
||
repoId: repo.id,
|
||
authoritative: scan.ok,
|
||
source: scan.ok ? 'git' : 'metadata-fallback',
|
||
worktrees: detected
|
||
}
|
||
}
|
||
|
||
private isRuntimeWorktreeVisible(worktree: Worktree): boolean {
|
||
const repo = this.store?.getRepo(worktree.repoId)
|
||
if (!repo || !this.store) {
|
||
return true
|
||
}
|
||
return this.toRuntimeDetectedWorktree(repo, worktree).visible
|
||
}
|
||
|
||
private toRuntimeDetectedWorktree(repo: Repo, worktree: Worktree): 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: repo.connectionId ? [] : buildKnownOrcaWorkspaceLayouts(settings, repo),
|
||
isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo)
|
||
})
|
||
}
|
||
|
||
async showManagedWorktree(worktreeSelector: string) {
|
||
return await this.resolveWorktreeSelector(worktreeSelector)
|
||
}
|
||
|
||
async scanWorkspacePorts(repoId?: string): Promise<WorkspacePortScanResult> {
|
||
return scanWorkspacePortProbes(await this.getWorkspacePortProbes(repoId))
|
||
}
|
||
|
||
async killWorkspacePort(args: WorkspacePortKillRequest): Promise<WorkspacePortKillResult> {
|
||
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<WorkspacePortProbe[]> {
|
||
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): Promise<{
|
||
repoId: string
|
||
worktreeId: string
|
||
activated: boolean
|
||
}> {
|
||
this.assertGraphReady()
|
||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||
const repo = this.store?.getRepo(worktree.repoId)
|
||
if (!repo) {
|
||
throw new Error('repo_not_found')
|
||
}
|
||
|
||
// Why: inactive worktree terminal panes are renderer-owned and may not have
|
||
// live PTYs until the desktop activates the worktree and mounts them.
|
||
this.notifier?.activateWorktree(repo.id, worktree.id)
|
||
return { repoId: repo.id, worktreeId: worktree.id, activated: true }
|
||
}
|
||
|
||
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) ? preferredAgent : null
|
||
if (!agent) {
|
||
let detected: string[] = []
|
||
try {
|
||
detected = repo.connectionId
|
||
? await detectRemoteAgents({ connectionId: repo.connectionId })
|
||
: await detectInstalledAgents()
|
||
} catch {
|
||
detected = []
|
||
}
|
||
const typedDetected = detected.filter(isTuiAgent)
|
||
agent = pickTuiAgent(null, typedDetected)
|
||
}
|
||
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: NodeJS.Platform = repo.connectionId ? 'linux' : process.platform
|
||
const draftLaunchPlan = buildAgentDraftLaunchPlan({
|
||
agent,
|
||
draft: content,
|
||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||
platform: agentLaunchPlatform
|
||
})
|
||
if (draftLaunchPlan) {
|
||
return {
|
||
agent,
|
||
startup: {
|
||
command: draftLaunchPlan.launchCommand,
|
||
...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {})
|
||
}
|
||
}
|
||
}
|
||
|
||
const startupPlan = buildAgentStartupPlan({
|
||
agent,
|
||
prompt: '',
|
||
cmdOverrides: settings.agentCmdOverrides ?? {},
|
||
platform: agentLaunchPlatform,
|
||
allowEmptyPromptLaunch: true
|
||
})
|
||
if (!startupPlan) {
|
||
return null
|
||
}
|
||
return {
|
||
agent,
|
||
startup: {
|
||
command: startupPlan.launchCommand,
|
||
...(startupPlan.env ? { env: startupPlan.env } : {})
|
||
},
|
||
draftPaste: { agent, content }
|
||
}
|
||
}
|
||
|
||
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<void> {
|
||
const preset = TUI_AGENT_CONFIG[agent].preflightTrust
|
||
if (!preset) {
|
||
return
|
||
}
|
||
try {
|
||
const home = await this.resolveRemoteHome(connectionId)
|
||
const fsProvider = getSshFilesystemProvider(connectionId)
|
||
if (!home || !fsProvider) {
|
||
return
|
||
}
|
||
const absPath = await this.canonicalizeRemoteWorkspacePath(fsProvider, workspacePath)
|
||
if (preset === 'codex') {
|
||
await this.markRemoteCodexProjectTrusted(fsProvider, home, absPath)
|
||
} else if (preset === 'cursor') {
|
||
await this.markRemoteCursorWorkspaceTrusted(fsProvider, home, absPath)
|
||
} else if (preset === 'copilot') {
|
||
await this.markRemoteCopilotFolderTrusted(fsProvider, home, absPath)
|
||
}
|
||
} catch {
|
||
// Best-effort: the user can still accept the remote agent trust prompt manually.
|
||
}
|
||
}
|
||
|
||
private async resolveRemoteHome(connectionId: string): Promise<string | null> {
|
||
const mux = getActiveMultiplexer(connectionId)
|
||
if (!mux || mux.isDisposed?.()) {
|
||
return null
|
||
}
|
||
const result = (await mux.request('session.resolveHome', { path: '~' })) as {
|
||
resolvedPath?: unknown
|
||
}
|
||
const home = typeof result.resolvedPath === 'string' ? result.resolvedPath.trim() : ''
|
||
return home && home.startsWith('/') && !/[\u0000\r\n]/.test(home)
|
||
? home.replace(/\/$/, '')
|
||
: null
|
||
}
|
||
|
||
private async canonicalizeRemoteWorkspacePath(
|
||
fsProvider: IFilesystemProvider,
|
||
workspacePath: string
|
||
): Promise<string> {
|
||
try {
|
||
return await fsProvider.realpath(workspacePath)
|
||
} catch {
|
||
return workspacePath
|
||
}
|
||
}
|
||
|
||
private async readRemoteTextFile(
|
||
fsProvider: IFilesystemProvider,
|
||
filePath: string
|
||
): Promise<string> {
|
||
try {
|
||
const result = await fsProvider.readFile(filePath)
|
||
return result.isBinary ? '' : result.content
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
private async markRemoteCodexProjectTrusted(
|
||
fsProvider: IFilesystemProvider,
|
||
remoteHome: string,
|
||
workspacePath: string
|
||
): Promise<void> {
|
||
const codexDir = `${remoteHome}/.codex`
|
||
const configPath = `${codexDir}/config.toml`
|
||
const existing = await this.readRemoteTextFile(fsProvider, configPath)
|
||
const updated = upsertProjectTrustLevelInContent(existing, workspacePath, 'trusted')
|
||
if (updated === existing) {
|
||
return
|
||
}
|
||
await fsProvider.createDir(codexDir)
|
||
await fsProvider.writeFile(configPath, updated)
|
||
}
|
||
|
||
private async markRemoteCursorWorkspaceTrusted(
|
||
fsProvider: IFilesystemProvider,
|
||
remoteHome: string,
|
||
workspacePath: string
|
||
): Promise<void> {
|
||
const slug = workspacePath.replace(/^[\\/]+/, '').replace(/[\\/]+/g, '-')
|
||
if (!slug) {
|
||
return
|
||
}
|
||
const trustDir = `${remoteHome}/.cursor/projects/${slug}`
|
||
const trustFile = `${trustDir}/.workspace-trusted`
|
||
try {
|
||
await fsProvider.stat(trustFile)
|
||
return
|
||
} catch {
|
||
// Missing marker: write the same shape the local trust preset writes.
|
||
}
|
||
await fsProvider.createDir(trustDir)
|
||
await fsProvider.writeFile(
|
||
trustFile,
|
||
`${JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath }, null, 2)}\n`
|
||
)
|
||
}
|
||
|
||
private async markRemoteCopilotFolderTrusted(
|
||
fsProvider: IFilesystemProvider,
|
||
remoteHome: string,
|
||
workspacePath: string
|
||
): Promise<void> {
|
||
const configDir = `${remoteHome}/.copilot`
|
||
const configPath = `${configDir}/config.json`
|
||
const raw = await this.readRemoteTextFile(fsProvider, configPath)
|
||
let config: Record<string, unknown> = {}
|
||
if (raw.trim()) {
|
||
try {
|
||
const parsed = JSON.parse(raw)
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
config = parsed as Record<string, unknown>
|
||
}
|
||
} catch {
|
||
return
|
||
}
|
||
}
|
||
const existing = Array.isArray(config.trustedFolders)
|
||
? (config.trustedFolders as unknown[])
|
||
: []
|
||
if (existing.includes(workspacePath)) {
|
||
return
|
||
}
|
||
config.trustedFolders = [
|
||
...existing.filter((entry) => typeof entry === 'string'),
|
||
workspacePath
|
||
]
|
||
await fsProvider.createDir(configDir)
|
||
await fsProvider.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
||
}
|
||
|
||
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 waitForStartupDraftReady(handle: string, agent: TuiAgent): Promise<string | null> {
|
||
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<string | null>((resolve) => {
|
||
let settled = false
|
||
let recent = ''
|
||
let postHandshakeRecent = ''
|
||
let saw2004 = false
|
||
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 combined = recent + data
|
||
recent = combined.slice(-512)
|
||
if (!saw2004) {
|
||
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
|
||
if (markerIndex === -1) {
|
||
return
|
||
}
|
||
saw2004 = true
|
||
const postHandshakeChunk = combined.slice(markerIndex + DECSET_BRACKETED_PASTE.length)
|
||
if (readySignal === 'codex-composer-prompt') {
|
||
if (postHandshakeChunk.includes(CODEX_COMPOSER_PROMPT)) {
|
||
finish(ptyId)
|
||
return
|
||
}
|
||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||
return
|
||
}
|
||
postHandshakeRecent = postHandshakeChunk.slice(-512)
|
||
} else {
|
||
if (
|
||
readySignal === 'codex-composer-prompt' &&
|
||
(data.includes(CODEX_COMPOSER_PROMPT) ||
|
||
(postHandshakeRecent + data).includes(CODEX_COMPOSER_PROMPT))
|
||
) {
|
||
finish(ptyId)
|
||
return
|
||
}
|
||
postHandshakeRecent = (postHandshakeRecent + data).slice(-512)
|
||
}
|
||
if (readySignal !== 'codex-composer-prompt' && saw2004) {
|
||
armQuietTimer()
|
||
}
|
||
}
|
||
|
||
unsubscribe = this.subscribeToTerminalData(ptyId, observeData)
|
||
const replay = this.recentPtyOutputById.get(ptyId)
|
||
if (replay) {
|
||
observeData(replay)
|
||
}
|
||
hardTimer = setTimeout(() => finish(null), DRAFT_PASTE_READY_TIMEOUT_MS)
|
||
})
|
||
}
|
||
|
||
async createManagedWorktree(args: {
|
||
repoSelector: string
|
||
name: string
|
||
baseBranch?: string
|
||
branchNameOverride?: string
|
||
linkedIssue?: number | null
|
||
linkedPR?: number | null
|
||
linkedLinearIssue?: string
|
||
linkedGitLabMR?: number | null
|
||
linkedGitLabIssue?: 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
|
||
startup?: WorktreeStartupLaunch
|
||
startupDraft?: string
|
||
startupDraftPaste?: WorktreeStartupDraftPaste
|
||
lineage?: WorktreeLineageInput
|
||
}): Promise<CreateWorktreeResult> {
|
||
if (!this.store) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
|
||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||
const draftStartup = args.startupDraft
|
||
? await this.buildStartupForDraft(repo, args.startupDraft, args.createdWithAgent)
|
||
: null
|
||
const effectiveStartup = args.startup ?? draftStartup?.startup
|
||
const effectiveCreatedWithAgent = args.createdWithAgent ?? draftStartup?.agent
|
||
const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste
|
||
if (isFolderRepo(repo)) {
|
||
const now = Date.now()
|
||
const settings = this.store.getSettings()
|
||
const instanceId = randomUUID()
|
||
const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId)
|
||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||
instanceId,
|
||
displayName: args.displayName?.trim() || args.name,
|
||
lastActivityAt: now,
|
||
createdAt: now,
|
||
orcaCreatedAt: now,
|
||
orcaCreationSource: 'runtime',
|
||
orcaCreationWorkspaceLayout: {
|
||
path: settings.workspaceDir,
|
||
nestWorkspaces: settings.nestWorkspaces
|
||
},
|
||
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
|
||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||
...(args.linkedLinearIssue !== undefined
|
||
? { linkedLinearIssue: args.linkedLinearIssue }
|
||
: {}),
|
||
...(args.linkedGitLabIssue !== undefined
|
||
? { linkedGitLabIssue: args.linkedGitLabIssue }
|
||
: {}),
|
||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||
...(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.notifier?.worktreesChanged(repo.id)
|
||
const shouldActivate = args.activate === true || args.runHooks === true
|
||
let warning: string | undefined
|
||
let didSpawnStartup = false
|
||
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
|
||
})
|
||
if (effectiveDraftPaste) {
|
||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||
}
|
||
didSpawnStartup = true
|
||
} 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.notifier?.activateWorktree(repo.id, worktree.id, undefined, effectiveStartup)
|
||
} else {
|
||
this.notifier?.activateWorktree(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
|
||
}
|
||
},
|
||
...(warning ? { warning } : {})
|
||
}
|
||
}
|
||
if (repo.connectionId) {
|
||
return await this.createManagedRemoteWorktree(repo, {
|
||
...args,
|
||
...(effectiveStartup ? { startup: effectiveStartup } : {}),
|
||
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
|
||
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
|
||
})
|
||
}
|
||
const lineageInput =
|
||
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
|
||
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
|
||
const settings = this.store.getSettings()
|
||
const requestedName = args.name
|
||
const requestedDisplayName = args.displayName?.trim() || undefined
|
||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||
const username = getGitUsername(repo.path)
|
||
const branchName = await resolveCreateBranchName(
|
||
repo.path,
|
||
args.branchNameOverride,
|
||
sanitizedName,
|
||
settings,
|
||
username
|
||
)
|
||
|
||
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
|
||
if (!baseBranch) {
|
||
// Why: getDefaultBaseRef returns null when no suitable ref exists.
|
||
// Don't fabricate 'origin/main' — passing it to addWorktree would
|
||
// produce an opaque git failure. Surface a clear error so the CLI
|
||
// caller can pick an explicit --base ref.
|
||
throw new Error(
|
||
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
|
||
)
|
||
}
|
||
|
||
const checkoutExistingBranch = await canCheckoutExistingLocalBranch(
|
||
repo.path,
|
||
branchName,
|
||
baseBranch
|
||
)
|
||
const branchConflictKind = checkoutExistingBranch
|
||
? null
|
||
: await getBranchConflictKind(repo.path, branchName, baseBranch)
|
||
if (branchConflictKind) {
|
||
throw new Error(
|
||
`Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.`
|
||
)
|
||
}
|
||
|
||
if (!checkoutExistingBranch) {
|
||
let existingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
|
||
try {
|
||
existingPR = await getPRForBranch(repo.path, branchName)
|
||
} catch {
|
||
// Why: worktree creation should not hard-fail on transient GitHub reachability
|
||
// issues because git state is still the source of truth for whether the
|
||
// worktree can be created locally.
|
||
}
|
||
if (existingPR) {
|
||
throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`)
|
||
}
|
||
}
|
||
|
||
let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings)
|
||
// Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the
|
||
// distro filesystem. If home lookup fails, still validate against the
|
||
// configured workspace dir so the traversal guard is never bypassed.
|
||
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
|
||
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
|
||
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
|
||
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
|
||
const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch)
|
||
if (remoteTrackingBase) {
|
||
const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
|
||
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
|
||
repo.path,
|
||
remoteTrackingBase
|
||
)
|
||
if (!refreshResult.ok) {
|
||
throw new Error(
|
||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||
)
|
||
}
|
||
if (!hadLocalBaseRef && !(await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase))) {
|
||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||
}
|
||
} else {
|
||
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
|
||
// Why: local bases keep legacy best-effort fetch behavior. Remote-tracking
|
||
// bases fail closed above because stale create-from-base is worse than a
|
||
// clear retryable error.
|
||
try {
|
||
await this.fetchRemoteWithCache(repo.path, remote)
|
||
} 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
|
||
)
|
||
}
|
||
|
||
const existingBranchOption = { checkoutExistingBranch }
|
||
if (sparseDirectories.length > 0) {
|
||
await (checkoutExistingBranch
|
||
? addSparseWorktree(
|
||
repo.path,
|
||
worktreePath,
|
||
branchName,
|
||
sparseDirectories,
|
||
baseBranch,
|
||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||
existingBranchOption
|
||
)
|
||
: addSparseWorktree(
|
||
repo.path,
|
||
worktreePath,
|
||
branchName,
|
||
sparseDirectories,
|
||
baseBranch,
|
||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||
))
|
||
} else {
|
||
await (checkoutExistingBranch
|
||
? addWorktree(
|
||
repo.path,
|
||
worktreePath,
|
||
branchName,
|
||
baseBranch,
|
||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||
false,
|
||
existingBranchOption
|
||
)
|
||
: addWorktree(
|
||
repo.path,
|
||
worktreePath,
|
||
branchName,
|
||
baseBranch,
|
||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||
))
|
||
}
|
||
|
||
let configuredPushTarget: GitPushTarget | undefined
|
||
if (preparedPushTarget) {
|
||
configuredPushTarget = await configureCreatedWorktreePushTarget(
|
||
worktreePath,
|
||
branchName,
|
||
preparedPushTarget
|
||
)
|
||
}
|
||
|
||
const gitWorktrees = 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()
|
||
const displayNameMeta = requestedDisplayName
|
||
? { displayName: requestedDisplayName }
|
||
: shouldSetDisplayName(requestedName, branchName, sanitizedName)
|
||
? { displayName: requestedName }
|
||
: {}
|
||
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(),
|
||
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: {
|
||
path: settings.workspaceDir,
|
||
nestWorkspaces: settings.nestWorkspaces
|
||
},
|
||
...displayNameMeta,
|
||
baseRef: baseBranch,
|
||
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
|
||
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
|
||
...(sparseDirectories.length > 0
|
||
? {
|
||
sparseDirectories,
|
||
sparseBaseRef: baseBranch,
|
||
sparsePresetId: args.sparseCheckout?.presetId
|
||
}
|
||
: {}),
|
||
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
|
||
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
|
||
...(args.linkedLinearIssue !== undefined
|
||
? { linkedLinearIssue: args.linkedLinearIssue }
|
||
: {}),
|
||
...(args.linkedGitLabIssue !== undefined
|
||
? { linkedGitLabIssue: args.linkedGitLabIssue }
|
||
: {}),
|
||
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||
...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}),
|
||
...(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)
|
||
let lineage: WorktreeLineage | null = null
|
||
const lineageWarnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : []
|
||
if (lineageResolution.kind === 'lineage') {
|
||
const childInstanceId = meta.instanceId
|
||
const parentInstanceId = lineageResolution.parent.instanceId
|
||
if (childInstanceId && parentInstanceId && this.store.setWorktreeLineage) {
|
||
lineage = this.store.setWorktreeLineage(worktreeId, {
|
||
worktreeId,
|
||
worktreeInstanceId: childInstanceId,
|
||
parentWorktreeId: lineageResolution.parent.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: now
|
||
})
|
||
} else {
|
||
lineageWarnings.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 (
|
||
settings.experimentalWorktreeSymlinks &&
|
||
repo.symlinkPaths &&
|
||
repo.symlinkPaths.length > 0
|
||
) {
|
||
await createWorktreeSymlinks(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 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')
|
||
const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
|
||
if (shouldRunSetup && hooks?.scripts.setup) {
|
||
if (this.authoritativeWindowId !== null) {
|
||
try {
|
||
// Why: CLI-created worktrees must use the same runner-script path as the
|
||
// renderer create flow so repo-committed `orca.yaml` setup hooks run in
|
||
// the visible first terminal instead of a hidden background shell with
|
||
// different failure and prompt behavior.
|
||
setup = createSetupRunnerScript(repo, worktreePath, hooks.scripts.setup)
|
||
} 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).then((result) => {
|
||
if (!result.success) {
|
||
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
|
||
}
|
||
})
|
||
}
|
||
} else if (hooks?.scripts.setup) {
|
||
// Runtime RPC calls have no renderer trust prompt, so hooks require explicit CLI opt-in.
|
||
warning = `orca.yaml setup hook skipped for ${worktreePath}; pass --run-hooks to run it.`
|
||
console.warn(`[hooks] ${warning}`)
|
||
}
|
||
|
||
this.invalidateResolvedWorktreeCache()
|
||
// 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.notifier?.worktreesChanged(repo.id)
|
||
const shouldActivate = args.activate === true || args.runHooks === true
|
||
let didSpawnStartup = false
|
||
let didSpawnSetup = false
|
||
let startupTerminalHandle: string | null = null
|
||
if (effectiveStartup && 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: effectiveStartup.command,
|
||
env: effectiveStartup.env
|
||
})
|
||
if (effectiveDraftPaste) {
|
||
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
|
||
}
|
||
didSpawnStartup = true
|
||
startupTerminalHandle = terminal.handle
|
||
} 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 (didSpawnStartup && setup && this.ptyController?.spawn) {
|
||
try {
|
||
// Why: reveal-on-adopt can create the startup tab before renderer
|
||
// activation handles setup. Honor the same split-vs-tab setting here
|
||
// because renderer activation will skip setup once the startup tab exists.
|
||
const setupCommand = buildSetupRunnerCommand(
|
||
setup.runnerScriptPath,
|
||
process.platform === 'win32' ? 'windows' : 'posix'
|
||
)
|
||
const setupLaunchMode =
|
||
(this.store.getSettings() as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
|
||
.setupScriptLaunchMode ?? 'new-tab'
|
||
if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') {
|
||
if (!startupTerminalHandle) {
|
||
throw new Error('startup_terminal_missing')
|
||
}
|
||
await this.splitTerminal(startupTerminalHandle, {
|
||
direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
|
||
command: setupCommand,
|
||
env: setup.envVars,
|
||
activate: false
|
||
})
|
||
} else {
|
||
await this.createTerminal(`id:${worktree.id}`, {
|
||
title: 'Setup',
|
||
command: setupCommand,
|
||
env: setup.envVars
|
||
})
|
||
}
|
||
didSpawnSetup = true
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
warning = warning
|
||
? `${warning} Also failed to create the setup terminal for ${worktreePath}: ${message}`
|
||
: `Failed to create the setup 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 activationSetup = didSpawnSetup ? undefined : setup
|
||
if (effectiveStartup && !didSpawnStartup) {
|
||
this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, effectiveStartup)
|
||
} else {
|
||
this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup)
|
||
}
|
||
} else if (this.ptyController?.spawn) {
|
||
try {
|
||
if (!didSpawnStartup) {
|
||
await this.createTerminal(`id:${worktree.id}`)
|
||
}
|
||
if (setup && !didSpawnSetup) {
|
||
await this.createTerminal(`id:${worktree.id}`, {
|
||
title: 'Setup',
|
||
command: buildSetupRunnerCommand(
|
||
setup.runnerScriptPath,
|
||
process.platform === 'win32' ? 'windows' : 'posix'
|
||
),
|
||
env: setup.envVars
|
||
})
|
||
}
|
||
} 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}`)
|
||
}
|
||
}
|
||
return {
|
||
worktree: {
|
||
...worktree,
|
||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||
childWorktreeIds: [],
|
||
lineage,
|
||
git: created
|
||
},
|
||
...(lineageInput ? { lineage, warnings: lineageWarnings } : {}),
|
||
...(setup ? { setup } : {}),
|
||
...(warning ? { warning } : {})
|
||
}
|
||
}
|
||
|
||
private async createManagedRemoteWorktree(
|
||
repo: Repo,
|
||
args: {
|
||
name: string
|
||
baseBranch?: string
|
||
branchNameOverride?: string
|
||
linkedIssue?: number | null
|
||
linkedPR?: number | null
|
||
linkedLinearIssue?: string
|
||
linkedGitLabMR?: number | null
|
||
linkedGitLabIssue?: number | null
|
||
comment?: string
|
||
displayName?: string
|
||
workspaceStatus?: string
|
||
manualOrder?: number
|
||
sparseCheckout?: { directories: string[]; presetId?: string }
|
||
pushTarget?: GitPushTarget
|
||
runHooks?: boolean
|
||
setupDecision?: 'run' | 'skip' | 'inherit'
|
||
createdWithAgent?: TuiAgent
|
||
startup?: WorktreeStartupLaunch
|
||
startupDraftPaste?: WorktreeStartupDraftPaste
|
||
}
|
||
): Promise<CreateWorktreeResult> {
|
||
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.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.linkedGitLabMR != null ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
|
||
...(args.linkedGitLabIssue != null ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
|
||
...(args.pushTarget ? { pushTarget: args.pushTarget } : {}),
|
||
...(args.workspaceStatus ? { workspaceStatus: args.workspaceStatus as never } : {}),
|
||
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
|
||
...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {})
|
||
},
|
||
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.notifier?.worktreesChanged(repo.id)
|
||
|
||
let warning = result.warning
|
||
let didSpawnStartup = false
|
||
let didSpawnSetup = false
|
||
let startupTerminalHandle: string | null = null
|
||
if (args.startup && 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: args.startup.command,
|
||
env: args.startup.env
|
||
})
|
||
if (args.startupDraftPaste) {
|
||
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
|
||
}
|
||
didSpawnStartup = true
|
||
startupTerminalHandle = terminal.handle
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
warning = warning
|
||
? `${warning} Also failed to create the startup terminal for ${result.worktree.path}: ${message}`
|
||
: `Failed to create the startup terminal for ${result.worktree.path}: ${message}`
|
||
}
|
||
}
|
||
|
||
if (didSpawnStartup && result.setup && this.ptyController?.spawn) {
|
||
try {
|
||
// Why: remote/mobile task creates spawn the agent terminal in runtime,
|
||
// so renderer activation never receives the setup payload. Runtime
|
||
// must apply the same user-selected split-vs-tab setup placement.
|
||
const setupCommand = buildSetupRunnerCommand(
|
||
result.setup.runnerScriptPath,
|
||
isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix'
|
||
)
|
||
const setupLaunchMode =
|
||
(this.store.getSettings() as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
|
||
.setupScriptLaunchMode ?? 'new-tab'
|
||
if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') {
|
||
if (!startupTerminalHandle) {
|
||
throw new Error('startup_terminal_missing')
|
||
}
|
||
await this.splitTerminal(startupTerminalHandle, {
|
||
direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
|
||
command: setupCommand,
|
||
env: result.setup.envVars,
|
||
activate: false
|
||
})
|
||
} else {
|
||
await this.createTerminal(`path:${result.worktree.path}`, {
|
||
title: 'Setup',
|
||
command: setupCommand,
|
||
env: result.setup.envVars
|
||
})
|
||
}
|
||
didSpawnSetup = true
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err)
|
||
warning = warning
|
||
? `${warning} Also failed to create the setup terminal for ${result.worktree.path}: ${message}`
|
||
: `Failed to create the setup terminal for ${result.worktree.path}: ${message}`
|
||
}
|
||
}
|
||
|
||
if (!args.startup && this.ptyController?.spawn) {
|
||
try {
|
||
await this.createTerminal(`path:${result.worktree.path}`)
|
||
if (result.setup && !didSpawnSetup) {
|
||
await this.createTerminal(`path:${result.worktree.path}`, {
|
||
title: 'Setup',
|
||
command: buildSetupRunnerCommand(
|
||
result.setup.runnerScriptPath,
|
||
isWindowsAbsolutePathLike(result.setup.runnerScriptPath) ? 'windows' : 'posix'
|
||
),
|
||
env: result.setup.envVars
|
||
})
|
||
}
|
||
} 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}`
|
||
}
|
||
}
|
||
|
||
return warning ? { ...result, warning } : result
|
||
}
|
||
|
||
/**
|
||
* 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): Promise<string> {
|
||
const cacheKey = `${repoPath}::${remote}`
|
||
const cached = this.canonicalFetchKeyCache.get(cacheKey)
|
||
if (cached !== undefined) {
|
||
return cached
|
||
}
|
||
let resolved = cacheKey
|
||
try {
|
||
const { stdout } = await gitExecFileAsync(
|
||
['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
||
{ cwd: repoPath }
|
||
)
|
||
const commonDir = stdout.trim()
|
||
if (commonDir) {
|
||
resolved = `${commonDir}::${remote}`
|
||
}
|
||
} catch {
|
||
// Fall through to the caller-provided path. The fetch still runs from
|
||
// repoPath; this key only controls cache sharing.
|
||
}
|
||
this.canonicalFetchKeyCache.set(cacheKey, resolved)
|
||
return resolved
|
||
}
|
||
|
||
private enqueueRemoteFetch(
|
||
remoteKey: string,
|
||
runFetch: () => Promise<RemoteFetchResult>
|
||
): Promise<RemoteFetchResult> {
|
||
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
|
||
}
|
||
|
||
async getOrStartRemoteFetch(repoPath: string, remote: string): Promise<RemoteFetchResult> {
|
||
const key = await this.getCanonicalFetchKey(repoPath, remote)
|
||
const lastAt = this.fetchLastCompletedAt.get(key)
|
||
if (lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS) {
|
||
// 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 })
|
||
.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.fetchLastCompletedAt.set(key, Date.now())
|
||
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
|
||
): Promise<RemoteFetchResult> {
|
||
const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote)
|
||
const key = await this.getCanonicalFetchKey(repoPath, `base:${base.remote}:${base.branch}`)
|
||
const existing = this.fetchInflight.get(key)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
|
||
const promise = this.enqueueRemoteFetch(remoteKey, () =>
|
||
gitExecFileAsync(
|
||
['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`],
|
||
{ cwd: repoPath }
|
||
)
|
||
.then((): RemoteFetchResult => ({ 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): Promise<void> {
|
||
await this.getOrStartRemoteFetch(repoPath, remote)
|
||
}
|
||
|
||
async resolveRemoteTrackingBase(
|
||
repoPath: string,
|
||
baseBranch: string
|
||
): Promise<RemoteTrackingBase | null> {
|
||
let remotes: string[]
|
||
try {
|
||
const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath })
|
||
remotes = stdout
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
const remote = remotes
|
||
.filter((candidate) => baseBranch.startsWith(`${candidate}/`))
|
||
.sort((a, b) => b.length - a.length)[0]
|
||
if (!remote) {
|
||
return null
|
||
}
|
||
const branch = baseBranch.slice(remote.length + 1)
|
||
if (!branch) {
|
||
return null
|
||
}
|
||
return {
|
||
remote,
|
||
branch,
|
||
ref: `refs/remotes/${remote}/${branch}`,
|
||
base: baseBranch
|
||
}
|
||
}
|
||
|
||
async hasRemoteTrackingRef(repoPath: string, base: RemoteTrackingBase): Promise<boolean> {
|
||
try {
|
||
await gitExecFileAsync(['rev-parse', '--verify', `${base.ref}^{commit}`], { cwd: repoPath })
|
||
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<RemoteFetchResult>
|
||
}): Promise<void> {
|
||
const stillCurrent = (): boolean =>
|
||
this.optimisticReconcileTokens.get(args.worktreeId) === args.token
|
||
const emit = (event: Omit<WorktreeBaseStatusEvent, 'repoId' | 'worktreeId' | 'base'>): 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<string> => {
|
||
// 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.<name>.pushRemote (explicit per-branch override)
|
||
// 2) remote.pushDefault (workspace-wide override)
|
||
// 3) branch.<name>.remote (tracked remote)
|
||
// 4) the base ref's own remote (matches resolveRemoteTrackingBase)
|
||
// 5) `origin` as a final fallback.
|
||
const tryConfig = async (key: string): Promise<string | null> => {
|
||
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<void> => {
|
||
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 meta = this.store.getWorktreeMeta(wt.id)
|
||
const base =
|
||
meta?.baseRef || meta?.sparseBaseRef || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
|
||
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)
|
||
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)
|
||
const drift = getRemoteDrift(wt.path, 'HEAD', base)
|
||
if (!drift) {
|
||
return null
|
||
}
|
||
const recentSubjects = getRecentDriftSubjects(wt.path, 'HEAD', base, DRIFT_PROBE_SUBJECT_LIMIT)
|
||
return { base, behind: drift.behind, recentSubjects }
|
||
}
|
||
|
||
async updateManagedWorktreeMeta(
|
||
worktreeSelector: string,
|
||
updates: Partial<WorktreeMeta> & {
|
||
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?.noParent === true) {
|
||
this.store.removeWorktreeLineage?.(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',
|
||
'Workspace instance identity was unavailable.'
|
||
)
|
||
}
|
||
if (!this.store.setWorktreeLineage) {
|
||
throw new RuntimeLineageError(
|
||
'LINEAGE_PARENT_CONTEXT_MISSING',
|
||
'Workspace lineage storage was unavailable.'
|
||
)
|
||
}
|
||
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: Date.now()
|
||
})
|
||
}
|
||
this.store.setWorktreeMeta(worktree.id, omitUndefinedProperties(metaUpdates))
|
||
// Why: unlike renderer-initiated optimistic updates, CLI callers need an
|
||
// explicit push so the editor refreshes metadata changed outside the UI.
|
||
this.invalidateResolvedWorktreeCache()
|
||
this.notifier?.worktreesChanged(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.notifier?.reposChanged()
|
||
return { updated }
|
||
}
|
||
|
||
async resolveManagedPrBase(args: {
|
||
repoSelector: string
|
||
prNumber: number
|
||
headRefName?: string
|
||
isCrossRepository?: boolean
|
||
}): Promise<{ baseBranch: 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 gitExec = sshGitProvider
|
||
? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path)
|
||
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, { 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)
|
||
|
||
return resolveGitHubPrStartPoint({
|
||
repoPath: repo.path,
|
||
prNumber: args.prNumber,
|
||
headRefName: args.headRefName,
|
||
isCrossRepository: args.isCrossRepository,
|
||
connectionId: repo.connectionId ?? null,
|
||
gitExec,
|
||
resolveRemote
|
||
})
|
||
}
|
||
|
||
async resolveManagedMrBase(args: {
|
||
repoSelector: string
|
||
mrIid: number
|
||
sourceBranch?: string
|
||
isCrossRepository?: boolean
|
||
}): Promise<{ baseBranch: 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 gitExec = sshGitProvider
|
||
? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path)
|
||
: (gitArgs: string[]) => gitExecFileAsync(gitArgs, { cwd: repo.path })
|
||
|
||
let sourceBranch = args.sourceBranch?.trim() ?? ''
|
||
let isCrossRepository = args.isCrossRepository === true
|
||
|
||
if (!sourceBranch) {
|
||
let remote: string
|
||
try {
|
||
remote = await this.resolveGitLabIssueSourceRemote(
|
||
repo.path,
|
||
repo.issueSourcePreference,
|
||
repo.connectionId ?? null
|
||
)
|
||
} catch (error) {
|
||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||
}
|
||
const knownHosts = await getGlabKnownHosts()
|
||
const projectRef = await getGitLabProjectRefForRemote(
|
||
repo.path,
|
||
remote,
|
||
knownHosts,
|
||
repo.connectionId ?? null
|
||
)
|
||
if (!projectRef) {
|
||
return { error: 'No GitLab project found for this repository.' }
|
||
}
|
||
const item = await getGitLabWorkItemByProjectRef(
|
||
repo.path,
|
||
projectRef,
|
||
args.mrIid,
|
||
'mr',
|
||
repo.connectionId ?? null
|
||
)
|
||
if (!item || item.type !== 'mr') {
|
||
return { error: `MR !${args.mrIid} not found.` }
|
||
}
|
||
sourceBranch = (item.branchName ?? '').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
|
||
)
|
||
} catch (error) {
|
||
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
|
||
}
|
||
|
||
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 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.` }
|
||
}
|
||
return { baseBranch: sha }
|
||
}
|
||
|
||
try {
|
||
await gitExec([
|
||
'fetch',
|
||
remote,
|
||
`+refs/heads/${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.` }
|
||
}
|
||
return { baseBranch: remoteRef, pushTarget: { remoteName: remote, branchName: sourceBranch } }
|
||
}
|
||
|
||
private async resolveGitLabIssueSourceRemote(
|
||
repoPath: string,
|
||
preference?: Repo['issueSourcePreference'],
|
||
connectionId?: string | null
|
||
): Promise<string> {
|
||
const knownHosts = await getGlabKnownHosts()
|
||
if (preference === 'origin') {
|
||
const origin = await getGitLabProjectRefForRemote(
|
||
repoPath,
|
||
'origin',
|
||
knownHosts,
|
||
connectionId
|
||
)
|
||
if (origin) {
|
||
return 'origin'
|
||
}
|
||
throw new Error('No GitLab project found for origin.')
|
||
}
|
||
if (preference === 'upstream') {
|
||
const upstream = await getGitLabProjectRefForRemote(
|
||
repoPath,
|
||
'upstream',
|
||
knownHosts,
|
||
connectionId
|
||
)
|
||
if (upstream) {
|
||
return 'upstream'
|
||
}
|
||
const origin = await getGitLabProjectRefForRemote(
|
||
repoPath,
|
||
'origin',
|
||
knownHosts,
|
||
connectionId
|
||
)
|
||
if (origin) {
|
||
return 'origin'
|
||
}
|
||
throw new Error('No GitLab project found for upstream or origin.')
|
||
}
|
||
const upstream = await getGitLabProjectRefForRemote(
|
||
repoPath,
|
||
'upstream',
|
||
knownHosts,
|
||
connectionId
|
||
)
|
||
if (upstream) {
|
||
return 'upstream'
|
||
}
|
||
const origin = await getGitLabProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
|
||
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)
|
||
}
|
||
|
||
private async resolveWorktreeRemovalTarget(
|
||
worktreeSelector: string,
|
||
force: boolean
|
||
): Promise<RuntimeWorktreeRemovalTarget> {
|
||
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 (!force || !(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: force-delete retries 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
|
||
}
|
||
}
|
||
|
||
async removeManagedWorktree(
|
||
worktreeSelector: string,
|
||
force = false,
|
||
runHooks = false
|
||
): Promise<{ warning?: string }> {
|
||
if (!this.store) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
const store = this.store
|
||
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, force)
|
||
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<{ warning?: string }> => {
|
||
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
|
||
}).catch((err) => {
|
||
console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)
|
||
})
|
||
}
|
||
store.removeWorktreeMeta(removalTarget.id)
|
||
deleteWorktreeHistoryDir(removalTarget.id)
|
||
this.invalidateResolvedWorktreeCache()
|
||
this.notifier?.worktreesChanged(repo.id)
|
||
return {}
|
||
}
|
||
const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null
|
||
const registeredWorktrees = repo.connectionId
|
||
? await provider!.listWorktrees(repo.path)
|
||
: await listWorktrees(repo.path)
|
||
const removedMeta = store.getWorktreeMeta(removalTarget.id)
|
||
const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget
|
||
const registeredWorktree = findRegisteredDeletableWorktree(
|
||
repo.path,
|
||
removalTarget.path,
|
||
registeredWorktrees
|
||
)
|
||
if (!registeredWorktree) {
|
||
if (force && (await isRuntimeWorktreePathMissing(repo, removalTarget.path))) {
|
||
// Why: runtime clients can retry a force delete after another surface
|
||
// already removed the worktree. Finish Orca cleanup without 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
|
||
))
|
||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||
store.removeWorktreeMeta(removalTarget.id)
|
||
this.invalidateResolvedWorktreeCache()
|
||
invalidateAuthorizedRootsCache()
|
||
this.notifier?.worktreesChanged(repo.id)
|
||
return {}
|
||
}
|
||
throw new Error(`Refusing to delete unregistered worktree path: ${removalTarget.path}`)
|
||
}
|
||
const canonicalWorktreePath = registeredWorktree.path
|
||
const deleteBranch = removedMeta?.preserveBranchOnDelete !== true
|
||
if (repo.connectionId) {
|
||
await (deleteBranch
|
||
? provider!.removeWorktree(canonicalWorktreePath, force)
|
||
: provider!.removeWorktree(canonicalWorktreePath, force, { deleteBranch }))
|
||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||
provider!,
|
||
repo.path,
|
||
removalTarget.id,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||
store.removeWorktreeMeta(removalTarget.id)
|
||
this.invalidateResolvedWorktreeCache()
|
||
invalidateAuthorizedRootsCache()
|
||
this.notifier?.worktreesChanged(repo.id)
|
||
return {}
|
||
}
|
||
|
||
const hooks = getEffectiveHooks(repo)
|
||
let warning: string | undefined
|
||
if (hooks?.scripts.archive && runHooks) {
|
||
const result = await runHook('archive', canonicalWorktreePath, repo)
|
||
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}`)
|
||
}
|
||
|
||
let shouldTearDownPtys = true
|
||
try {
|
||
await assertWorktreeCleanForRemoval(canonicalWorktreePath, force)
|
||
} catch (error) {
|
||
if (!isOrphanCompatiblePreflightError(error)) {
|
||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||
}
|
||
// Why: orphan cleanup does not need live shells to be killed first,
|
||
// and preflight did not prove the worktree is cleanly removable.
|
||
shouldTearDownPtys = false
|
||
}
|
||
|
||
const localProvider = this.getLocalProvider()
|
||
if (localProvider && shouldTearDownPtys) {
|
||
// Why: once preflight proves normal deletion is clean, kill PTYs before
|
||
// git-level removal so shells cannot keep the directory busy. This also
|
||
// closes the headless-CLI leak for confirmed-removable worktrees.
|
||
await killAllProcessesForWorktree(removalTarget.id, {
|
||
runtime: this,
|
||
localProvider
|
||
})
|
||
.then((r) => {
|
||
const total = r.runtimeStopped + r.providerStopped + r.registryStopped
|
||
if (total > 0) {
|
||
// Why (design §4.4 observability): breadcrumb lets ops
|
||
// distinguish a renderer-state-induced leak (diff-path purge
|
||
// non-empty) from a backend-induced one (nothing to kill but
|
||
// memory still pinned). Emit only when the sweep actually did
|
||
// work so steady-state logs stay quiet.
|
||
console.info(
|
||
`[worktree-teardown] ${removalTarget.id} killed runtime=${r.runtimeStopped} provider=${r.providerStopped} registry=${r.registryStopped}`
|
||
)
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.warn(`[worktree-teardown] failed for ${removalTarget.id}:`, err)
|
||
})
|
||
}
|
||
|
||
try {
|
||
await (deleteBranch
|
||
? removeWorktree(repo.path, canonicalWorktreePath, force)
|
||
: removeWorktree(repo.path, canonicalWorktreePath, force, { deleteBranch }))
|
||
} catch (error) {
|
||
if (isOrphanedWorktreeError(error)) {
|
||
if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) {
|
||
await rm(canonicalWorktreePath, { recursive: true, force: true }).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/<name>`) 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 }).catch(() => {})
|
||
await cleanupUnusedWorktreePushTargetRemote(
|
||
repo.path,
|
||
removalTarget.id,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||
store.removeWorktreeMeta(removalTarget.id)
|
||
this.invalidateResolvedWorktreeCache()
|
||
invalidateAuthorizedRootsCache()
|
||
this.notifier?.worktreesChanged(repo.id)
|
||
return {
|
||
...(warning ? { warning } : {})
|
||
}
|
||
}
|
||
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force))
|
||
}
|
||
|
||
await cleanupUnusedWorktreePushTargetRemote(
|
||
repo.path,
|
||
removalTarget.id,
|
||
removedPushTarget,
|
||
store
|
||
)
|
||
this.clearOptimisticReconcileToken(removalTarget.id)
|
||
store.removeWorktreeMeta(removalTarget.id)
|
||
this.invalidateResolvedWorktreeCache()
|
||
invalidateAuthorizedRootsCache()
|
||
this.notifier?.worktreesChanged(repo.id)
|
||
return {
|
||
...(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<RuntimeTerminalRename> {
|
||
this.assertGraphReady()
|
||
const pty = this.getLivePtyForHandle(handle)
|
||
if (pty) {
|
||
pty.pty.title = 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.record.tabId, title }
|
||
}
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
this.notifier?.renameTerminal(leaf.tabId, title)
|
||
return { handle, tabId: leaf.tabId, title }
|
||
}
|
||
|
||
async createTerminal(
|
||
worktreeSelector?: string,
|
||
opts: {
|
||
command?: string
|
||
env?: Record<string, string>
|
||
title?: string
|
||
focus?: boolean
|
||
rendererBacked?: boolean
|
||
activate?: boolean
|
||
tabId?: string
|
||
leafId?: string
|
||
} = {}
|
||
): Promise<RuntimeTerminalCreate> {
|
||
// 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 ? this.getAvailableAuthoritativeWindow() : null
|
||
const shouldCreateInBackground =
|
||
worktreeSelector !== undefined &&
|
||
((opts.focus !== true && 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 worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||
const repo = this.store?.getRepo(worktree.repoId)
|
||
const 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 = opts.tabId?.trim()
|
||
const canAdoptPaneIdentity =
|
||
hintedTabId !== undefined &&
|
||
isValidHostTerminalTabId(hintedTabId) &&
|
||
opts.leafId !== undefined &&
|
||
isTerminalLeafId(opts.leafId)
|
||
const tabId = canAdoptPaneIdentity ? (hintedTabId as string) : randomUUID()
|
||
const leafId = canAdoptPaneIdentity ? (opts.leafId as string) : randomUUID()
|
||
const paneKey = makePaneKey(tabId, leafId)
|
||
const env = {
|
||
...opts.env,
|
||
ORCA_PANE_KEY: paneKey,
|
||
ORCA_TAB_ID: tabId,
|
||
ORCA_WORKTREE_ID: worktree.id
|
||
}
|
||
const result = await this.ptyController.spawn({
|
||
cols: 120,
|
||
rows: 40,
|
||
cwd: worktree.path,
|
||
command: opts.command,
|
||
env,
|
||
connectionId: repo?.connectionId ?? null,
|
||
worktreeId: worktree.id,
|
||
preAllocatedHandle
|
||
})
|
||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||
this.registerPty(result.id, worktree.id)
|
||
const pty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||
if (pty) {
|
||
pty.title = opts.title ?? null
|
||
pty.tabId = tabId
|
||
pty.paneKey = paneKey
|
||
}
|
||
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
|
||
let surface: RuntimeTerminalCreate['surface'] = 'background'
|
||
if (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(worktree.id, {
|
||
ptyId: result.id,
|
||
title: opts.title ?? null,
|
||
activate: opts.activate === true,
|
||
tabId,
|
||
leafId
|
||
})
|
||
surface = 'visible'
|
||
} catch (err) {
|
||
console.warn(`[terminal-create] failed to create inactive tab for ${result.id}:`, err)
|
||
}
|
||
}
|
||
return { handle, worktreeId: worktree.id, title: opts.title ?? null, surface }
|
||
}
|
||
|
||
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 worktreeId = worktreeSelector
|
||
? (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||
: undefined
|
||
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 (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 ?? opts.title ?? '' })
|
||
}
|
||
}
|
||
ipcMain.on('terminal:tabCreateReply', handler)
|
||
win.webContents.send('terminal:requestTabCreate', {
|
||
requestId,
|
||
worktreeId,
|
||
command: opts.command,
|
||
title: opts.title,
|
||
activate: opts.focus === true || opts.activate === true
|
||
})
|
||
})
|
||
|
||
// 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, worktreeId: worktreeId ?? '', title: reply.title, surface: 'visible' }
|
||
}
|
||
|
||
async createMobileSessionTerminal(
|
||
worktreeSelector: string,
|
||
opts: { afterTabId?: string; targetGroupId?: string; command?: string; activate?: boolean } = {}
|
||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||
this.assertGraphReady()
|
||
const worktreeId = (await this.resolveWorktreeSelector(worktreeSelector)).id
|
||
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 win = this.getAvailableAuthoritativeWindow()
|
||
if (!win) {
|
||
return await this.createHeadlessMobileSessionTerminal(
|
||
worktreeId,
|
||
opts.activate !== false,
|
||
opts.afterTabId,
|
||
opts.command
|
||
)
|
||
}
|
||
const requestId = randomUUID()
|
||
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 (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 ?? '' })
|
||
}
|
||
}
|
||
ipcMain.on('terminal:tabCreateReply', handler)
|
||
win.webContents.send('terminal:requestTabCreate', {
|
||
requestId,
|
||
worktreeId,
|
||
afterTabId: afterDesktopTabId,
|
||
targetGroupId: opts.targetGroupId,
|
||
command: opts.command
|
||
})
|
||
})
|
||
|
||
if (opts.activate !== false) {
|
||
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
|
||
}
|
||
return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId)
|
||
}
|
||
|
||
private async createHeadlessMobileSessionTerminal(
|
||
worktreeId: string,
|
||
activate: boolean,
|
||
afterTabId?: string,
|
||
command?: string
|
||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||
const terminal = await this.createTerminal(`id:${worktreeId}`, { focus: false, command })
|
||
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()
|
||
const tab: RuntimeMobileSessionTerminalTab = {
|
||
type: 'terminal',
|
||
id: `${parentTabId}::${leafId}`,
|
||
parentTabId,
|
||
leafId,
|
||
title: terminal.title ?? livePty.pty.title ?? 'Terminal',
|
||
parentLayout: {
|
||
root: { type: 'leaf', leafId },
|
||
activeLeafId: leafId,
|
||
expandedLeafId: null
|
||
},
|
||
isActive: activate
|
||
}
|
||
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||
const tabs = (existing?.tabs ?? [])
|
||
.filter((candidate) => candidate.id !== tab.id)
|
||
.map((candidate) => ({
|
||
...candidate,
|
||
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,
|
||
activeGroupId: existing?.activeGroupId ?? null,
|
||
activeTabId: activate ? tab.id : (existing?.activeTabId ?? null),
|
||
activeTabType: activate ? 'terminal' : (existing?.activeTabType ?? null),
|
||
tabs
|
||
}
|
||
this.mobileSessionTabsByWorktree.set(worktreeId, next)
|
||
const result = this.toMobileSessionTabsResult(next)
|
||
for (const listener of this.mobileSessionTabListeners) {
|
||
listener(result)
|
||
}
|
||
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,
|
||
timeoutMs = 10_000
|
||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||
const existing = this.findMobileTerminalSurface(worktreeId, parentTabId)
|
||
if (existing) {
|
||
return Promise.resolve(existing)
|
||
}
|
||
|
||
return new Promise<RuntimeMobileSessionCreateTerminalResult>((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 surface after creation'))
|
||
}, timeoutMs)
|
||
|
||
const check = (): void => {
|
||
const next = this.findMobileTerminalSurface(worktreeId, parentTabId)
|
||
if (!next) {
|
||
return
|
||
}
|
||
clearTimeout(timer)
|
||
const idx = this.graphSyncCallbacks.indexOf(check)
|
||
if (idx !== -1) {
|
||
this.graphSyncCallbacks.splice(idx, 1)
|
||
}
|
||
resolve(next)
|
||
}
|
||
this.graphSyncCallbacks.push(check)
|
||
check()
|
||
})
|
||
}
|
||
|
||
private findMobileTerminalSurface(
|
||
worktreeId: string,
|
||
parentTabId: string
|
||
): 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
|
||
}
|
||
return {
|
||
tab,
|
||
publicationEpoch: result.publicationEpoch,
|
||
snapshotVersion: result.snapshotVersion
|
||
}
|
||
}
|
||
|
||
private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise<string> {
|
||
const existing = this.resolveHandleForTab(tabId)
|
||
if (existing) {
|
||
return Promise.resolve(existing)
|
||
}
|
||
|
||
return new Promise<string>((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: the graph sync may have fired between the initial check and
|
||
// callback registration. Re-check immediately to avoid a missed wake-up.
|
||
check()
|
||
})
|
||
}
|
||
|
||
// Why: mobile clients may subscribe before the PTY spawns (the left pane
|
||
// of a new workspace). Instead of bailing with a bare scrollback+end,
|
||
// wait for the PTY to appear so the subscribe can proceed with phone-fit.
|
||
waitForLeafPtyId(handle: string, timeoutMs = 10_000): Promise<string> {
|
||
const leaf = this.resolveLeafForHandle(handle)
|
||
if (leaf?.ptyId) {
|
||
return Promise.resolve(leaf.ptyId)
|
||
}
|
||
|
||
// Why: when the ptyId changes from null to a real value, the old handle
|
||
// is invalidated (deleted from this.handles). Capture the tabId+leafId
|
||
// now so we can look up the leaf directly even after handle invalidation.
|
||
const record = this.handles.get(handle)
|
||
const savedTabId = record?.tabId ?? null
|
||
const savedLeafId = record?.leafId ?? null
|
||
|
||
return new Promise<string>((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 PTY to spawn'))
|
||
}, timeoutMs)
|
||
|
||
const check = (): void => {
|
||
// Try the handle first (works if handle wasn't invalidated yet)
|
||
let ptyId = this.resolveLeafForHandle(handle)?.ptyId
|
||
// Why: when ptyId transitions null→real, issueHandle invalidates the
|
||
// old handle. Fall back to direct leaf lookup by the saved coordinates.
|
||
if (!ptyId && savedTabId && savedLeafId) {
|
||
const directLeaf = this.leaves.get(this.getLeafKey(savedTabId, savedLeafId))
|
||
ptyId = directLeaf?.ptyId ?? null
|
||
}
|
||
if (ptyId) {
|
||
clearTimeout(timer)
|
||
const idx = this.graphSyncCallbacks.indexOf(check)
|
||
if (idx !== -1) {
|
||
this.graphSyncCallbacks.splice(idx, 1)
|
||
}
|
||
resolve(ptyId)
|
||
}
|
||
}
|
||
this.graphSyncCallbacks.push(check)
|
||
check()
|
||
})
|
||
}
|
||
|
||
// Why: a leaf appears in the graph before its PTY spawns. If we issue a
|
||
// handle while ptyId is null, the next graph sync after PTY spawn will
|
||
// change ptyId and invalidate the handle. Wait for a connected PTY so
|
||
// the handle is stable and immediately usable for send/read/wait.
|
||
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): Promise<RuntimeTerminalFocus> {
|
||
this.assertGraphReady()
|
||
const pty = this.getLivePtyForHandle(handle)
|
||
if (pty) {
|
||
if (!pty.pty.connected) {
|
||
throw new Error('terminal_exited')
|
||
}
|
||
const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '')
|
||
const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, {
|
||
ptyId: pty.pty.ptyId,
|
||
title: pty.pty.title ?? pty.pty.lastOscTitle,
|
||
...(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
|
||
}
|
||
}
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId, leaf.leafId)
|
||
return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId }
|
||
}
|
||
|
||
async closeTerminal(handle: string): Promise<RuntimeTerminalClose> {
|
||
this.assertGraphReady()
|
||
const pty = this.getLivePtyForHandle(handle)
|
||
if (pty) {
|
||
const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false
|
||
return { handle, tabId: pty.record.tabId, ptyKilled }
|
||
}
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
let ptyKilled = false
|
||
if (leaf.ptyId) {
|
||
ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false
|
||
}
|
||
// Why: killing the PTY in a multi-pane tab is sufficient — the renderer's
|
||
// PTY exit handler already calls PaneManager.closePane() for split layouts.
|
||
// Sending an additional IPC close would race with the exit handler and
|
||
// incorrectly close the entire tab (the pane count drops to 1 before the
|
||
// IPC arrives, triggering the single-pane fallback path).
|
||
// We only send the notifier close when the PTY wasn't killed (e.g. PTY not
|
||
// yet spawned) or when this is the only pane in the 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 splitTerminal(
|
||
handle: string,
|
||
opts: {
|
||
direction?: 'horizontal' | 'vertical'
|
||
command?: string
|
||
env?: Record<string, string>
|
||
activate?: boolean
|
||
} = {}
|
||
): Promise<RuntimeTerminalSplit> {
|
||
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'
|
||
|
||
// Why: snapshot current leaf keys for this tab so we can detect the new
|
||
// pane that appears after the split via graph sync delta.
|
||
const leafKeysBefore = new Set<string>()
|
||
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
|
||
})
|
||
|
||
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<string, string>
|
||
activate?: boolean
|
||
} = {}
|
||
): Promise<RuntimeTerminalSplit> {
|
||
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 worktree = await this.resolveWorktreeSelector(`id:${pty.worktreeId}`)
|
||
const repo = this.store?.getRepo(worktree.repoId)
|
||
const leafId = randomUUID()
|
||
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
|
||
const paneKey = makePaneKey(parentTabId, leafId)
|
||
const result = await this.ptyController.spawn({
|
||
cols: 120,
|
||
rows: 40,
|
||
cwd: worktree.path,
|
||
command: opts.command,
|
||
env: {
|
||
...opts.env,
|
||
ORCA_PANE_KEY: paneKey,
|
||
ORCA_TAB_ID: parentTabId,
|
||
ORCA_WORKTREE_ID: worktree.id
|
||
},
|
||
connectionId: repo?.connectionId ?? null,
|
||
worktreeId: worktree.id,
|
||
preAllocatedHandle
|
||
})
|
||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||
this.registerPty(result.id, worktree.id)
|
||
const createdPty = this.getOrCreatePtyWorktreeRecord(result.id)
|
||
if (createdPty) {
|
||
createdPty.tabId = parentTabId
|
||
createdPty.paneKey = paneKey
|
||
}
|
||
|
||
try {
|
||
await this.notifier?.revealTerminalSession?.(worktree.id, {
|
||
ptyId: result.id,
|
||
title: null,
|
||
activate: opts.activate !== false,
|
||
tabId: parentTabId,
|
||
leafId,
|
||
splitFromLeafId: parsedPaneKey.leafId,
|
||
splitDirection: direction
|
||
})
|
||
} catch (error) {
|
||
this.ptyController.kill?.(result.id)
|
||
throw error
|
||
}
|
||
|
||
return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 }
|
||
}
|
||
|
||
private waitForNewLeafInTab(
|
||
tabId: string,
|
||
existingLeafKeys: Set<string>,
|
||
timeoutMs = 10_000
|
||
): Promise<string> {
|
||
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<string>((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): Promise<{ stopped: number }> {
|
||
// Why: this mutates live PTYs, so the runtime must reject it while the
|
||
// renderer graph is reloading instead of acting on cached leaf ownership.
|
||
const graphEpoch = this.captureReadyGraphEpoch()
|
||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||
this.assertStableReadyGraph(graphEpoch)
|
||
const ptyIds = new Set<string>()
|
||
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 (this.ptyController?.kill(ptyId)) {
|
||
stopped += 1
|
||
}
|
||
}
|
||
return { stopped }
|
||
}
|
||
|
||
async hasTerminalsForWorktree(worktreeSelector: string): Promise<boolean> {
|
||
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: any renderer reload tears down the published live graph, so live
|
||
// terminal handles must become stale immediately instead of being reused
|
||
// against whatever the renderer rebuilds next.
|
||
this.rendererGraphEpoch += 1
|
||
this.graphStatus = 'reloading'
|
||
this.rememberDetachedPreAllocatedLeaves()
|
||
this.handles.clear()
|
||
this.handleByLeafKey.clear()
|
||
// Why: handleByPtyId maps ptyId → pre-allocated CLI handle (ORCA_TERMINAL_HANDLE).
|
||
// These must survive renderer reloads so CLI agents can keep controlling the
|
||
// same terminal across graph rebuilds — adoptPreAllocatedHandle re-links
|
||
// them when the new graph arrives.
|
||
this.rejectAllWaiters('terminal_handle_stale')
|
||
this.refreshWritableFlags()
|
||
}
|
||
|
||
markGraphReady(windowId: number): void {
|
||
if (windowId !== this.authoritativeWindowId) {
|
||
return
|
||
}
|
||
this.graphStatus = 'ready'
|
||
this.refreshWritableFlags()
|
||
}
|
||
|
||
markGraphUnavailable(windowId: number): void {
|
||
if (windowId !== this.authoritativeWindowId) {
|
||
return
|
||
}
|
||
// Why: once the authoritative renderer graph disappears, Orca must fail
|
||
// closed for live-terminal operations instead of guessing from old state.
|
||
if (this.graphStatus !== 'unavailable') {
|
||
this.rendererGraphEpoch += 1
|
||
}
|
||
this.graphStatus = 'unavailable'
|
||
this.authoritativeWindowId = null
|
||
this.rememberDetachedPreAllocatedLeaves()
|
||
this.tabs.clear()
|
||
this.leaves.clear()
|
||
this.handles.clear()
|
||
this.handleByLeafKey.clear()
|
||
// Why: same as markRendererReloading — 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 async resolveWorktreeSelector(selector: string): Promise<ResolvedWorktree> {
|
||
const worktrees = await this.listResolvedWorktrees()
|
||
let candidates: ResolvedWorktree[]
|
||
|
||
if (selector === 'active') {
|
||
throw new Error('selector_not_found')
|
||
}
|
||
|
||
if (selector.startsWith('id:')) {
|
||
candidates = worktrees.filter((worktree) => worktree.id === selector.slice(3))
|
||
} else if (selector.startsWith('path:')) {
|
||
candidates = worktrees.filter((worktree) =>
|
||
runtimePathsEqual(worktree.path, selector.slice(5))
|
||
)
|
||
} else if (selector.startsWith('branch:')) {
|
||
const branchSelector = selector.slice(7)
|
||
candidates = worktrees.filter((worktree) =>
|
||
branchSelectorMatches(worktree.branch, branchSelector)
|
||
)
|
||
} 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 validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void {
|
||
const childWorktreeId = child.id
|
||
const parentWorktreeId = parent.id
|
||
if (childWorktreeId === parentWorktreeId) {
|
||
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A workspace cannot parent itself.')
|
||
}
|
||
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<string>([childWorktreeId])
|
||
while (cursor) {
|
||
if (visited.has(cursor)) {
|
||
throw new RuntimeLineageError(
|
||
'LINEAGE_PARENT_CYCLE',
|
||
'Parent workspace 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<WorktreeLineageResolution> {
|
||
if (!input) {
|
||
return { kind: 'none', warnings: [] }
|
||
}
|
||
|
||
if (input.noParent === true && input.parentWorktree) {
|
||
throw new RuntimeLineageError(
|
||
'LINEAGE_PARENT_CONTEXT_CONFLICT',
|
||
'Choose either --parent-worktree or --no-parent, not both.'
|
||
)
|
||
}
|
||
|
||
if (input.noParent === true) {
|
||
return { kind: 'none', warnings: [] }
|
||
}
|
||
|
||
if (input.parentWorktree) {
|
||
try {
|
||
const parent = await this.resolveWorktreeSelector(input.parentWorktree)
|
||
return {
|
||
kind: 'lineage',
|
||
parent,
|
||
origin: 'cli',
|
||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
|
||
}
|
||
} catch {
|
||
throw new RuntimeLineageError(
|
||
'LINEAGE_PARENT_NOT_FOUND',
|
||
'Parent workspace was not found.',
|
||
{
|
||
nextSteps: [
|
||
'Run `orca worktree list` and pass a valid --parent-worktree selector.',
|
||
'Retry with --no-parent to create without lineage.'
|
||
]
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
const warnings: WorktreeLineageWarning[] = []
|
||
const candidates: WorktreeLineageCandidate[] = []
|
||
let cwdCandidate: WorktreeLineageCandidate | null = null
|
||
let terminalContextResolved = false
|
||
|
||
if (input.orchestrationContext?.parentWorktreeId) {
|
||
try {
|
||
candidates.push({
|
||
source: 'orchestration-context',
|
||
parent: await this.resolveWorktreeSelector(
|
||
`id:${input.orchestrationContext.parentWorktreeId}`
|
||
)
|
||
})
|
||
} 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.resolveWorktreeSelector(`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: terminal handles can go stale during reloads or SSH reconnects.
|
||
// A valid orchestration parent is still authoritative, so keep resolving
|
||
// other inferred candidates instead of dropping lineage completely.
|
||
warnings.push({
|
||
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
|
||
message:
|
||
'Worktree created, but Orca could not validate the caller terminal as a parent workspace.',
|
||
details: { callerTerminalHandle: input.callerTerminalHandle }
|
||
})
|
||
}
|
||
}
|
||
|
||
if (input.cwdParentWorktree) {
|
||
try {
|
||
cwdCandidate = {
|
||
source: 'cwd-context',
|
||
parent: await this.resolveWorktreeSelector(input.cwdParentWorktree)
|
||
}
|
||
} catch {
|
||
warnings.push({
|
||
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
|
||
message:
|
||
'Worktree created, but Orca could not validate the current directory as a parent workspace.',
|
||
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.id !== first.parent.id)
|
||
if (conflict) {
|
||
return {
|
||
kind: 'none',
|
||
warnings: [
|
||
{
|
||
code: 'LINEAGE_PARENT_CONTEXT_CONFLICT',
|
||
message: 'Worktree created, but Orca could not prove which parent workspace caused it.',
|
||
details: {
|
||
terminalParentWorktreeId: candidates.find((c) => c.source === 'terminal-context')
|
||
?.parent.id,
|
||
orchestrationParentWorktreeId: candidates.find(
|
||
(c) => c.source === 'orchestration-context'
|
||
)?.parent.id
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
const preferred =
|
||
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<WorktreeLineageCandidate | null> {
|
||
const db = this.getOrchestrationDbIfAvailable()
|
||
const dispatch = db?.getDispatchContext(taskId)
|
||
// Why: agent-created task records may never be dispatched, but the
|
||
// creating terminal still identifies the parent workspace for descendants.
|
||
const parentHandle =
|
||
dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle
|
||
if (!parentHandle) {
|
||
return null
|
||
}
|
||
try {
|
||
const terminal = await this.showTerminal(parentHandle)
|
||
return {
|
||
source: 'orchestration-context',
|
||
parent: await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`),
|
||
taskId
|
||
}
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
private getOrchestrationDbIfAvailable(): OrchestrationDb | null {
|
||
try {
|
||
return this._orchestrationDb ?? this.getOrchestrationDb()
|
||
} catch {
|
||
return this._orchestrationDb
|
||
}
|
||
}
|
||
|
||
async hydrateInferredWorktreeLineage(): Promise<void> {
|
||
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.id === worktree.id) {
|
||
continue
|
||
}
|
||
try {
|
||
this.validateLineageParent(worktree, candidate.parent)
|
||
} catch {
|
||
continue
|
||
}
|
||
store.setWorktreeLineage(worktree.id, {
|
||
worktreeId: worktree.id,
|
||
worktreeInstanceId: worktree.instanceId,
|
||
parentWorktreeId: candidate.parent.id,
|
||
parentWorktreeInstanceId: candidate.parent.instanceId,
|
||
origin: 'orchestration',
|
||
capture: { source: 'orchestration-context', confidence: 'inferred' },
|
||
taskId,
|
||
createdAt: Date.now()
|
||
})
|
||
}
|
||
}
|
||
|
||
async listWorktreeLineage(): Promise<Record<string, WorktreeLineage>> {
|
||
await this.hydrateInferredWorktreeLineage()
|
||
return this.store?.getAllWorktreeLineage?.() ?? {}
|
||
}
|
||
|
||
private async resolveRepoSelector(selector: string): Promise<Repo> {
|
||
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 assertHostIntegrationRepoIsLocal(repo: Repo, operation: string): void {
|
||
if (repo.connectionId) {
|
||
throw new Error(`${operation}_unsupported_for_ssh_repo`)
|
||
}
|
||
}
|
||
|
||
private requireStore(): Store {
|
||
if (!this.store) {
|
||
throw new Error('runtime_unavailable')
|
||
}
|
||
return this.store as unknown as Store
|
||
}
|
||
|
||
private async listResolvedWorktrees(): Promise<ResolvedWorktree[]> {
|
||
if (!this.store) {
|
||
return []
|
||
}
|
||
const now = Date.now()
|
||
if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) {
|
||
return this.resolvedWorktreeCache.worktrees
|
||
}
|
||
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<ResolvedWorktree[]> {
|
||
if (!this.store) {
|
||
return []
|
||
}
|
||
const now = Date.now()
|
||
const perRepoWorktrees = await Promise.all(
|
||
this.store.getRepos().map(async (repo) => {
|
||
if (isFolderRepo(repo)) {
|
||
return listRuntimeFolderWorkspaces(this.requireStore(), repo).map((worktree) => ({
|
||
...worktree,
|
||
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 RPCs share this path. A slow repo scan should
|
||
// degrade one repo's metadata, not block all terminal/session loading.
|
||
const scan = await withTimeout(
|
||
this.listRepoWorktreesForResolution(repo),
|
||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS,
|
||
{ ok: false, worktrees: [] }
|
||
)
|
||
const gitWorktrees = scan.worktrees
|
||
if (scan.ok) {
|
||
this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees)
|
||
}
|
||
const metaById = this.store?.getAllWorktreeMeta() ?? {}
|
||
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 the renderer's discovery-stamp path.
|
||
const existingMeta = metaById[worktreeId]
|
||
const meta =
|
||
existingMeta && existingMeta.instanceId
|
||
? existingMeta
|
||
: this.store?.setWorktreeMeta(worktreeId, {})
|
||
const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||
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 = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat())
|
||
// Why: terminal polling can be frequent, but git worktree state is still
|
||
// allowed to change outside Orca. A short TTL avoids shelling out on every
|
||
// read without pretending the cache is authoritative for long.
|
||
if (generation === this.resolvedWorktreeGeneration) {
|
||
this.resolvedWorktreeCache = {
|
||
worktrees,
|
||
expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS
|
||
}
|
||
}
|
||
return worktrees
|
||
}
|
||
|
||
private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] {
|
||
const lineageById = this.store?.getAllWorktreeLineage?.() ?? {}
|
||
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
|
||
const validLineageByChildId = new Map<string, WorktreeLineage>()
|
||
const childIdsByParentId = new Map<string, string[]>()
|
||
|
||
for (const [childId, lineage] of Object.entries(lineageById)) {
|
||
const child = worktreeById.get(childId)
|
||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||
if (
|
||
!child ||
|
||
!parent ||
|
||
child.instanceId !== lineage.worktreeInstanceId ||
|
||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||
) {
|
||
// Why: worktree IDs are path-derived. Instance checks keep replacement
|
||
// checkouts from appearing as children of stale same-path lineage.
|
||
continue
|
||
}
|
||
validLineageByChildId.set(childId, lineage)
|
||
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
|
||
children.push(childId)
|
||
childIdsByParentId.set(lineage.parentWorktreeId, children)
|
||
}
|
||
|
||
return worktrees.map((worktree) => {
|
||
const lineage = validLineageByChildId.get(worktree.id) ?? null
|
||
return {
|
||
...worktree,
|
||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||
childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [],
|
||
lineage
|
||
}
|
||
})
|
||
}
|
||
|
||
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 [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) {
|
||
if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) {
|
||
// Why: runtime selector scans can be the only scan before a path is
|
||
// reused. Once a successful scan proves the child is gone, stale
|
||
// lineage must not survive into the replacement checkout.
|
||
store.removeWorktreeLineage(childId)
|
||
}
|
||
if (
|
||
lineage.parentWorktreeId.startsWith(repoPrefix) &&
|
||
!liveIds.has(lineage.parentWorktreeId)
|
||
) {
|
||
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
|
||
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
|
||
// Why: preserving child lineage powers the repair UI, but a missing
|
||
// parent path only needs one fresh identity to keep same-path
|
||
// replacement checkouts from validating old lineage.
|
||
store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async listRepoWorktreesForResolution(repo: Repo): Promise<RuntimeWorktreeScanResult> {
|
||
if (!repo.connectionId) {
|
||
return { ok: true, worktrees: await listRepoWorktrees(repo) }
|
||
}
|
||
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<string, GitWorktreeInfo>()
|
||
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
|
||
const parsed = splitWorktreeId(worktreeId)
|
||
if (!parsed || parsed.repoId !== repo.id) {
|
||
continue
|
||
}
|
||
// Why: this mirrors desktop worktrees:list's disconnected-SSH fallback.
|
||
// Web clients should keep showing persisted SSH worktrees while the
|
||
// provider is reconnecting instead of dropping the repo to 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<Map<string, ResolvedWorktree>> {
|
||
return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree]))
|
||
}
|
||
|
||
private invalidateResolvedWorktreeCache(): void {
|
||
this.resolvedWorktreeGeneration += 1
|
||
this.resolvedWorktreeCache = null
|
||
}
|
||
|
||
private recordPtyWorktree(
|
||
ptyId: string,
|
||
worktreeId: string,
|
||
state: Partial<
|
||
Pick<RuntimePtyWorktreeRecord, 'connected' | 'lastOutputAt' | 'preview' | 'tabId' | 'paneKey'>
|
||
> = {}
|
||
): RuntimePtyWorktreeRecord {
|
||
let pty = this.ptysById.get(ptyId)
|
||
if (!pty) {
|
||
pty = {
|
||
ptyId,
|
||
worktreeId,
|
||
tabId: state.tabId ?? null,
|
||
paneKey: state.paneKey ?? null,
|
||
connected: state.connected ?? true,
|
||
lastExitCode: null,
|
||
lastAgentStatus: null,
|
||
lastOscTitle: null,
|
||
title: null,
|
||
lastOutputAt: state.lastOutputAt ?? null,
|
||
tailBuffer: [],
|
||
tailPartialLine: '',
|
||
tailTruncated: false,
|
||
tailLinesTotal: 0,
|
||
preview: state.preview ?? ''
|
||
}
|
||
this.ptysById.set(ptyId, pty)
|
||
// 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.tabId !== undefined) {
|
||
pty.tabId = state.tabId
|
||
}
|
||
if (state.paneKey !== undefined) {
|
||
pty.paneKey = state.paneKey
|
||
}
|
||
if (state.connected !== undefined) {
|
||
pty.connected = state.connected
|
||
}
|
||
if (state.lastOutputAt !== undefined) {
|
||
pty.lastOutputAt = maxTimestamp(pty.lastOutputAt, state.lastOutputAt)
|
||
}
|
||
if (state.preview !== undefined && state.preview.length > 0) {
|
||
pty.preview = state.preview
|
||
}
|
||
// Why: recordPtyWorktree is the common lifecycle point for every path that
|
||
// resolves a PTY's worktree, including renderer restore and controller list.
|
||
advertisedUrlWatcher.bindPty(ptyId, worktreeId)
|
||
return pty
|
||
}
|
||
|
||
private makeRuntimePaneKey(
|
||
leaf: Pick<RuntimeSyncedLeaf, 'tabId' | 'leafId' | 'paneRuntimeId'>
|
||
): 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 Electron reloads.
|
||
return this.recordPtyWorktree(ptyId, inferredWorktreeId)
|
||
}
|
||
|
||
private async refreshPtyWorktreeRecordsFromController(
|
||
resolvedWorktrees: ResolvedWorktree[]
|
||
): Promise<void> {
|
||
if (!this.ptyController?.listProcesses) {
|
||
return
|
||
}
|
||
const sessionsResult = await withTimeoutResult(
|
||
this.ptyController.listProcesses(),
|
||
PTY_CONTROLLER_LIST_TIMEOUT_MS
|
||
)
|
||
if (!sessionsResult.ok) {
|
||
// Why: a transient controller failure is not evidence that retained PTYs exited.
|
||
return
|
||
}
|
||
const sessions = sessionsResult.value
|
||
const livePtyIds = new Set(sessions.map((session) => session.id))
|
||
for (const session of sessions) {
|
||
const worktreeId =
|
||
inferWorktreeIdFromPtyId(session.id) ??
|
||
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd)
|
||
if (worktreeId) {
|
||
this.recordPtyWorktree(session.id, worktreeId, { connected: true })
|
||
}
|
||
}
|
||
for (const pty of this.ptysById.values()) {
|
||
if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) {
|
||
pty.connected = false
|
||
}
|
||
}
|
||
}
|
||
|
||
private pruneDisconnectedPtyTranscript(pty: RuntimePtyWorktreeRecord): void {
|
||
if (pty.connected) {
|
||
return
|
||
}
|
||
// Why: disconnected PTY records can stay addressable for status/exit reads,
|
||
// but their retained transcripts must not accumulate after the process dies.
|
||
pty.tailBuffer = []
|
||
pty.tailPartialLine = ''
|
||
pty.tailTruncated = false
|
||
pty.tailLinesTotal = 0
|
||
}
|
||
|
||
private leafExistsForPty(ptyId: string): boolean {
|
||
for (const leaf of this.leaves.values()) {
|
||
if (leaf.ptyId === ptyId) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
private getSummaryForRuntimeWorktreeId(
|
||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||
resolvedWorktrees: ResolvedWorktree[],
|
||
runtimeWorktreeId: string
|
||
): RuntimeWorktreePsSummary | null {
|
||
const exact = summaries.get(runtimeWorktreeId)
|
||
if (exact) {
|
||
return exact
|
||
}
|
||
const parsed = parseRuntimeWorktreeId(runtimeWorktreeId)
|
||
if (!parsed) {
|
||
return null
|
||
}
|
||
const resolved = resolvedWorktrees.find(
|
||
(worktree) =>
|
||
worktree.repoId === parsed.repoId &&
|
||
areWorktreePathsEqual(worktree.path, parsed.worktreePath)
|
||
)
|
||
return resolved ? (summaries.get(resolved.id) ?? null) : null
|
||
}
|
||
|
||
private buildTerminalSummary(
|
||
leaf: RuntimeLeafRecord,
|
||
worktreesById: Map<string, ResolvedWorktree>
|
||
): RuntimeTerminalSummary {
|
||
const worktree = worktreesById.get(leaf.worktreeId)
|
||
const tab = this.tabs.get(leaf.tabId) ?? null
|
||
|
||
return {
|
||
handle: this.issueHandle(leaf),
|
||
worktreeId: leaf.worktreeId,
|
||
worktreePath: worktree?.path ?? '',
|
||
branch: worktree?.branch ?? '',
|
||
tabId: leaf.tabId,
|
||
leafId: leaf.leafId,
|
||
title: tab?.title ?? null,
|
||
connected: leaf.connected,
|
||
writable: leaf.writable,
|
||
lastOutputAt: leaf.lastOutputAt,
|
||
preview: leaf.preview
|
||
}
|
||
}
|
||
|
||
private syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[] | undefined): void {
|
||
if (snapshots === undefined) {
|
||
return
|
||
}
|
||
const nextWorktrees = new Set<string>()
|
||
for (const snapshot of snapshots) {
|
||
nextWorktrees.add(snapshot.worktree)
|
||
const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree)
|
||
if (
|
||
!existing ||
|
||
snapshot.publicationEpoch !== existing.publicationEpoch ||
|
||
snapshot.snapshotVersion >= existing.snapshotVersion
|
||
) {
|
||
this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot)
|
||
}
|
||
}
|
||
for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) {
|
||
if (!nextWorktrees.has(worktreeId)) {
|
||
this.mobileSessionTabsByWorktree.delete(worktreeId)
|
||
this.notifyMobileSessionTabsRemoved(worktreeId)
|
||
}
|
||
}
|
||
}
|
||
|
||
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 listener of this.mobileSessionTabListeners) {
|
||
listener(removed)
|
||
}
|
||
}
|
||
|
||
notifyMobileSessionTabsChanged(worktreeId?: string): void {
|
||
if (!worktreeId) {
|
||
this.notifyMobileSessionTabSnapshots()
|
||
return
|
||
}
|
||
if (this.mobileSessionTabListeners.size === 0) {
|
||
return
|
||
}
|
||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||
if (!snapshot) {
|
||
return
|
||
}
|
||
// Why: browser bridge lifecycle events are already scoped by worktree; avoid
|
||
// fanning out every active workspace snapshot during navigation/tab churn.
|
||
const result = this.toMobileSessionTabsResult(snapshot)
|
||
for (const listener of this.mobileSessionTabListeners) {
|
||
listener(result)
|
||
}
|
||
}
|
||
|
||
private notifyMobileSessionTabSnapshots(): void {
|
||
if (this.mobileSessionTabListeners.size === 0) {
|
||
return
|
||
}
|
||
for (const snapshot of this.mobileSessionTabsByWorktree.values()) {
|
||
const result = this.toMobileSessionTabsResult(snapshot)
|
||
for (const listener of this.mobileSessionTabListeners) {
|
||
listener(result)
|
||
}
|
||
}
|
||
}
|
||
|
||
private getMobileSessionTabsForWorktree(worktreeId: string): RuntimeMobileSessionTabsResult {
|
||
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||
if (!snapshot) {
|
||
return {
|
||
worktree: worktreeId,
|
||
publicationEpoch: 'none',
|
||
snapshotVersion: 0,
|
||
activeGroupId: null,
|
||
activeTabId: null,
|
||
activeTabType: null,
|
||
tabs: []
|
||
}
|
||
}
|
||
return this.toMobileSessionTabsResult(snapshot)
|
||
}
|
||
|
||
private async resolveMobileMarkdownWorktreeId(
|
||
worktreeSelector: string,
|
||
tabId: string
|
||
): Promise<string> {
|
||
const worktreeId =
|
||
getExplicitWorktreeIdSelector(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<string, BrowserTabInfo> {
|
||
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<string> {
|
||
const ids = new Set<string>()
|
||
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<string>
|
||
): 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
|
||
}
|
||
|
||
private toMobileSessionTabsResult(
|
||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||
): RuntimeMobileSessionTabsResult {
|
||
const tabs: RuntimeMobileSessionClientTab[] = []
|
||
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
|
||
}
|
||
// Why: renderer session snapshots can lag behind BrowserView teardown or
|
||
// process swaps. Pairing clients should only see browser pages the main
|
||
// browser bridge can still route commands and screencasts 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 app-level 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 pty = liveLeaf ? null : this.findPtyForMobileTerminalTab(snapshot.worktree, tab)
|
||
const livePty = pty?.connected ? pty : null
|
||
const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null
|
||
const paneKey = isTerminalLeafId(tab.leafId)
|
||
? makePaneKey(tab.parentTabId, tab.leafId)
|
||
: `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}`
|
||
// Why: web/mobile clients hold these handles across renderer graph syncs;
|
||
// leaf handles are graph-epoch-bound, but PTY handles remain 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: leaf?.paneTitle ?? syncedTab?.title ?? pty?.title ?? tab.title,
|
||
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
|
||
...(tab.agentStatus ? { agentStatus: tab.agentStatus } : {}),
|
||
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
|
||
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
|
||
}
|
||
}
|
||
|
||
private findPtyForMobileTerminalTab(
|
||
worktreeId: string,
|
||
tab: RuntimeMobileSessionTerminalTab
|
||
): RuntimePtyWorktreeRecord | null {
|
||
const snapshotPtyId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? null
|
||
if (snapshotPtyId) {
|
||
const legacyPaneId = /^pane:(\d+)$/.exec(tab.leafId)?.[1] ?? null
|
||
const paneKey = isTerminalLeafId(tab.leafId)
|
||
? makePaneKey(tab.parentTabId, tab.leafId)
|
||
: `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}`
|
||
// Why: background desktop terminals may be unmounted, so the runtime
|
||
// has no live leaf. The renderer's saved leaf->PTY map is the bridge
|
||
// that lets paired web/mobile attach without spawning a duplicate tab.
|
||
// A saved id alone is not proof the daemon PTY is still alive.
|
||
return this.recordPtyWorktree(snapshotPtyId, worktreeId, {
|
||
tabId: tab.parentTabId,
|
||
paneKey,
|
||
connected: this.ptysById.get(snapshotPtyId)?.connected ?? false
|
||
})
|
||
}
|
||
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
|
||
}
|
||
|
||
// Why: group address resolution (Section 4.5) needs to query per-handle agent
|
||
// status without throwing on stale handles, so this returns null on any error.
|
||
getAgentStatusForHandle(handle: string): string | null {
|
||
try {
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
return leaf.lastAgentStatus
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
getAgentStatusOrchestrationContextForPaneKey(
|
||
paneKey: string
|
||
): AgentStatusOrchestrationContext | undefined {
|
||
const handle = this.getTerminalHandleForPaneKey(paneKey)
|
||
if (!handle) {
|
||
return undefined
|
||
}
|
||
const db = this.getOrchestrationDbIfAvailable()
|
||
const dispatch = db?.getActiveDispatchForTerminal(handle)
|
||
if (!dispatch) {
|
||
return undefined
|
||
}
|
||
const task = db?.getTask(dispatch.task_id)
|
||
const activeRun = 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,
|
||
...(parentTerminalHandle ? { parentTerminalHandle } : {}),
|
||
...(parentPaneKey ? { parentPaneKey } : {}),
|
||
...(activeRun?.coordinator_handle ? { coordinatorHandle: activeRun.coordinator_handle } : {}),
|
||
...(activeRun?.id ? { orchestrationRunId: activeRun.id } : {})
|
||
}
|
||
}
|
||
|
||
private getTerminalHandleForPaneKey(paneKey: string): string | null {
|
||
const parsed = parsePaneKey(paneKey)
|
||
if (parsed) {
|
||
const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId))
|
||
if (leaf?.ptyId) {
|
||
return this.issueHandle(leaf)
|
||
}
|
||
}
|
||
for (const pty of this.ptysById.values()) {
|
||
if (pty.paneKey === paneKey) {
|
||
return this.issuePtyHandle(pty)
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
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
|
||
}
|
||
return makePaneKey(record.tabId, record.leafId)
|
||
}
|
||
|
||
// Why: OSC title detection via onPtyData is the tightest signal for agent
|
||
// presence, but the runtime may not see PTY data for daemon-hosted terminals
|
||
// (the daemon adapter stubs getForegroundProcess). This checks three signals
|
||
// in order: (1) lastAgentStatus from PTY data OSC titles, (2) the renderer-
|
||
// synced tab title (which reflects OSC titles from the xterm instance), and
|
||
// (3) the PTY foreground process. Returns true if any signal indicates a
|
||
// non-shell agent is running.
|
||
async isTerminalRunningAgent(handle: string): Promise<boolean> {
|
||
try {
|
||
const pty = this.getLivePtyForHandle(handle)
|
||
if (pty) {
|
||
return await this.isPtyRunningAgent(pty.pty)
|
||
}
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
if (leaf.lastAgentStatus !== null) {
|
||
return true
|
||
}
|
||
// Why: check both the leaf-level pane title (synced from the renderer's
|
||
// runtimePaneTitlesByTabId) and the tab-level title. The tab title already
|
||
// includes OSC-enriched agent indicators (e.g. ✳ prefix) synced from the
|
||
// renderer's xterm instance.
|
||
const titleToCheck = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title
|
||
if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) {
|
||
return true
|
||
}
|
||
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
|
||
if (isCodexReadyPromptPreview(waitText)) {
|
||
return true
|
||
}
|
||
if (!leaf.ptyId || !this.ptyController) {
|
||
return false
|
||
}
|
||
const fg = await this.ptyController.getForegroundProcess(leaf.ptyId)
|
||
if (!fg) {
|
||
return false
|
||
}
|
||
return !isShellProcess(fg)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
private async isPtyRunningAgent(pty: RuntimePtyWorktreeRecord): Promise<boolean> {
|
||
if (pty.lastAgentStatus !== null) {
|
||
return true
|
||
}
|
||
const titleToCheck = pty.lastOscTitle ?? pty.title
|
||
if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) {
|
||
return true
|
||
}
|
||
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
|
||
if (isCodexReadyPromptPreview(waitText)) {
|
||
return true
|
||
}
|
||
if (!this.ptyController) {
|
||
return false
|
||
}
|
||
const fg = await this.ptyController.getForegroundProcess(pty.ptyId)
|
||
if (!fg) {
|
||
return false
|
||
}
|
||
return !isShellProcess(fg)
|
||
}
|
||
|
||
deliverPendingMessagesForHandle(handle: string): void {
|
||
try {
|
||
const { leaf } = this.getLiveLeafForHandle(handle)
|
||
if (leaf.lastAgentStatus === 'idle') {
|
||
this.deliverPendingMessages(leaf)
|
||
}
|
||
} catch {
|
||
// Unknown or stale handles cannot be pushed immediately; the persisted
|
||
// message remains available via explicit check or future idle delivery.
|
||
}
|
||
}
|
||
|
||
// Why: after a message is inserted for a recipient, any blocking
|
||
// orchestration.check --wait calls watching that handle must be woken
|
||
// so they can return the new message immediately instead of polling.
|
||
notifyMessageArrived(handle: string): void {
|
||
const waiters = this.messageWaitersByHandle.get(handle)
|
||
if (!waiters || waiters.size === 0) {
|
||
return
|
||
}
|
||
for (const waiter of [...waiters]) {
|
||
this.resolveMessageWaiter(waiter)
|
||
}
|
||
}
|
||
|
||
waitForMessage(
|
||
handle: string,
|
||
options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal }
|
||
): Promise<void> {
|
||
return new Promise((resolve) => {
|
||
const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS
|
||
|
||
const waiter: MessageWaiter = {
|
||
handle,
|
||
typeFilter: options?.typeFilter,
|
||
resolve,
|
||
timeout: null
|
||
}
|
||
|
||
// Why: if the caller aborts (socket closed on the RPC side — see design
|
||
// doc §3.1 counter-lifecycle), resolve immediately so the long-poll slot
|
||
// is released instead of counting down the full timeoutMs with a dead
|
||
// client on the other end.
|
||
const signal = options?.signal
|
||
const onAbort = (): void => {
|
||
this.removeMessageWaiter(waiter)
|
||
resolve()
|
||
}
|
||
if (signal) {
|
||
if (signal.aborted) {
|
||
resolve()
|
||
return
|
||
}
|
||
signal.addEventListener('abort', onAbort, { once: true })
|
||
}
|
||
|
||
waiter.timeout = setTimeout(() => {
|
||
if (signal) {
|
||
signal.removeEventListener('abort', onAbort)
|
||
}
|
||
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
|
||
}
|
||
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<string, ResolvedWorktree>
|
||
): RuntimeTerminalSummary {
|
||
const worktree = worktreesById.get(pty.worktreeId)
|
||
|
||
return {
|
||
handle: this.issuePtyHandle(pty),
|
||
worktreeId: pty.worktreeId,
|
||
worktreePath: worktree?.path ?? '',
|
||
branch: worktree?.branch ?? '',
|
||
tabId: `pty:${pty.ptyId}`,
|
||
leafId: `pty:${pty.ptyId}`,
|
||
title: pty.title ?? pty.lastOscTitle,
|
||
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/unavailability 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. If this synthetic PTY
|
||
// handle is valid, keep ptyId -> handle populated so summaries do not mint
|
||
// a second handle for the same terminal.
|
||
this.handleByPtyId.set(record.ptyId, handle)
|
||
return { record, pty }
|
||
}
|
||
|
||
private readPtyTerminal(
|
||
handle: string,
|
||
pty: RuntimePtyWorktreeRecord,
|
||
opts: { cursor?: number; limit?: number } = {}
|
||
): RuntimeTerminalRead {
|
||
return readTerminalTail({
|
||
handle,
|
||
status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown',
|
||
completedLines: pty.tailBuffer,
|
||
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 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 should
|
||
// survive renderer graph loss as long as the underlying 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: if the terminal exited, conditions like tui-idle can never be
|
||
// satisfied. Reject immediately instead of letting the poll interval
|
||
// spin 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: OSC title detection via onPtyData is the primary signal for tui-idle,
|
||
// but daemon-hosted terminals don't flow PTY data through the runtime, and
|
||
// some agents don't emit recognized titles on startup. This fallback polls
|
||
// two signals: (1) the renderer-synced tab title (reflects xterm's OSC title
|
||
// handler, works even for daemon terminals), and (2) the PTY foreground process
|
||
// + output quiescence. The poll self-cancels when the primary OSC path fires.
|
||
private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void {
|
||
waiter.pollInterval = setInterval(async () => {
|
||
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: check the renderer-synced title. For daemon-hosted terminals,
|
||
// this is the only path where OSC titles are visible to the runtime.
|
||
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 (isCodexReadyPromptPreview(leafWaitText)) {
|
||
if (waiter.pollInterval) {
|
||
clearInterval(waiter.pollInterval)
|
||
waiter.pollInterval = null
|
||
}
|
||
this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||
return
|
||
}
|
||
// Foreground process fallback: if the daemon/local provider can report
|
||
// the process and it's a non-shell with quiet output, treat as idle.
|
||
if (leaf.lastAgentStatus === null && leaf.ptyId && this.ptyController) {
|
||
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.
|
||
}
|
||
}, TUI_IDLE_POLL_INTERVAL_MS)
|
||
}
|
||
|
||
private startPtyTuiIdleFallbackPoll(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void {
|
||
waiter.pollInterval = setInterval(async () => {
|
||
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: background PTY handles can later be adopted by the renderer.
|
||
// Use that live xterm title as the same readiness signal as leaf handles.
|
||
if (
|
||
this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' ||
|
||
isCodexReadyPromptPreview(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) {
|
||
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.
|
||
}
|
||
}, 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 — when an agent transitions working→idle, check
|
||
// for unread orchestration messages addressed to that terminal and inject them
|
||
// into the PTY. This 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
|
||
}
|
||
|
||
// Why: Claude Code treats large single PTY writes as paste events and
|
||
// swallows a \r included in the same write. Send Enter separately after
|
||
// a delay so the agent processes the pasted message first. Stamp
|
||
// `delivered_at` only after \r is confirmed, so failed deliveries stay
|
||
// queued.
|
||
//
|
||
// Important (design doc §3.2, feedback #2): we stamp `delivered_at` here
|
||
// instead of flipping `read`. `read` is reserved for "a check-caller
|
||
// consumed this message." Flipping `read` on push-on-idle would hide the
|
||
// message from the coordinator's next `check --unread`, which is the
|
||
// exact bug feedback #2 reported. The two bits must stay independent.
|
||
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 still NULL) and will be re-delivered on the next
|
||
// idle transition.
|
||
}
|
||
}, 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<typeof connectLinear> {
|
||
return connectLinear(apiKey)
|
||
}
|
||
|
||
linearDisconnect(workspaceId?: string): { ok: true } {
|
||
disconnectLinear(workspaceId)
|
||
return { ok: true }
|
||
}
|
||
|
||
linearSelectWorkspace(workspaceId: LinearWorkspaceSelection): ReturnType<typeof getLinearStatus> {
|
||
return selectLinearWorkspace(workspaceId)
|
||
}
|
||
|
||
linearStatus(): ReturnType<typeof getLinearStatus> {
|
||
return getLinearStatus()
|
||
}
|
||
|
||
linearTestConnection(workspaceId?: string): ReturnType<typeof testLinearConnection> {
|
||
return testLinearConnection(workspaceId)
|
||
}
|
||
|
||
linearSearchIssues(
|
||
query: string,
|
||
limit = 20,
|
||
workspaceId?: LinearWorkspaceSelection
|
||
): ReturnType<typeof searchLinearIssues> {
|
||
return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId)
|
||
}
|
||
|
||
linearListIssues(
|
||
filter?: LinearListFilter,
|
||
limit = 20,
|
||
workspaceId?: LinearWorkspaceSelection
|
||
): ReturnType<typeof listLinearIssues> {
|
||
return listLinearIssues(filter, Math.min(Math.max(1, limit), 50), workspaceId)
|
||
}
|
||
|
||
linearCreateIssue(
|
||
teamId: string,
|
||
title: string,
|
||
description?: string,
|
||
workspaceId?: string,
|
||
parentIssueId?: string,
|
||
projectId?: string | null
|
||
): ReturnType<typeof createLinearIssue> {
|
||
return createLinearIssue(teamId, title, description, workspaceId, {
|
||
parentId: parentIssueId,
|
||
projectId
|
||
})
|
||
}
|
||
|
||
linearGetIssue(id: string, workspaceId?: string): ReturnType<typeof getLinearIssue> {
|
||
return getLinearIssue(id, workspaceId)
|
||
}
|
||
|
||
linearUpdateIssue(
|
||
id: string,
|
||
updates: LinearIssueUpdate,
|
||
workspaceId?: string
|
||
): ReturnType<typeof updateLinearIssue> {
|
||
return updateLinearIssue(id, updates, workspaceId)
|
||
}
|
||
|
||
linearAddIssueComment(
|
||
issueId: string,
|
||
body: string,
|
||
workspaceId?: string
|
||
): ReturnType<typeof addLinearIssueComment> {
|
||
return addLinearIssueComment(issueId, body, workspaceId)
|
||
}
|
||
|
||
linearIssueComments(
|
||
issueId: string,
|
||
workspaceId?: string
|
||
): ReturnType<typeof getLinearIssueComments> {
|
||
return getLinearIssueComments(issueId, workspaceId)
|
||
}
|
||
|
||
linearListTeams(workspaceId?: LinearWorkspaceSelection): ReturnType<typeof listLinearTeams> {
|
||
return listLinearTeams(workspaceId)
|
||
}
|
||
|
||
linearListProjects(
|
||
query?: string,
|
||
limit = 20,
|
||
workspaceId?: LinearWorkspaceSelection
|
||
): ReturnType<typeof listLinearProjects> {
|
||
return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId)
|
||
}
|
||
|
||
linearTeamStates(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamStates> {
|
||
return getLinearTeamStates(teamId, workspaceId)
|
||
}
|
||
|
||
linearTeamLabels(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamLabels> {
|
||
return getLinearTeamLabels(teamId, workspaceId)
|
||
}
|
||
|
||
linearTeamMembers(teamId: string, workspaceId?: string): ReturnType<typeof getLinearTeamMembers> {
|
||
return getLinearTeamMembers(teamId, workspaceId)
|
||
}
|
||
|
||
// ── Browser automation ──
|
||
|
||
private readonly browserCommands = new RuntimeBrowserCommands({
|
||
getAgentBrowserBridge: () => this.agentBrowserBridge,
|
||
resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector),
|
||
getAuthoritativeWindow: () => this.getAuthoritativeWindow(),
|
||
getAvailableAuthoritativeWindow: () => this.getAvailableAuthoritativeWindow()
|
||
})
|
||
|
||
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<RuntimeBrowserCommands['browserScreencast']>[0],
|
||
options: {
|
||
connectionId?: string
|
||
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
|
||
signal?: AbortSignal
|
||
emit: (result: BrowserScreencastResult) => void
|
||
}
|
||
): Promise<void> {
|
||
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 only supports one screencast per browser page. A stale paired
|
||
// web/mobile stream should not leave the next tab activation stuck on an
|
||
// already-active error or old viewport dimensions.
|
||
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<ReturnType<RuntimeBrowserCommands['browserScreencast']>> | 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<void>((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<ArrayBufferLike>): boolean | void => {
|
||
if (!readyEmitted) {
|
||
// Why: binary screencast frames are connection-scoped; clients learn the
|
||
// owning subscription from `ready`, so CDP frames must remain unacked
|
||
// until the stream's JSON ready event has been delivered.
|
||
return false
|
||
}
|
||
return options.sendBinary?.(bytes)
|
||
}
|
||
|
||
// Why: a phone can rotate before the first stream reaches `ready`, so it
|
||
// has no subscriptionId to unsubscribe. A same-socket replacement cancels
|
||
// and waits here instead of racing the active connection/page gates.
|
||
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: browser screencast frames are connection-scoped media. Registering
|
||
// cleanup ties Page.stopScreencast to the exact remote socket so hidden
|
||
// client panes and dropped connections do not 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)
|
||
|
||
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)
|
||
|
||
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 MAX_TAIL_LINES = 2000
|
||
const MAX_TAIL_CHARS = 256 * 1024
|
||
const MAX_TAIL_PARTIAL_CHARS = 4000
|
||
const DEFAULT_TERMINAL_READ_LIMIT = 120
|
||
const MAX_TERMINAL_READ_LIMIT = 2000
|
||
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
|
||
const MAX_PREVIEW_LINES = 6
|
||
const MAX_PREVIEW_CHARS = 300
|
||
const WORKTREE_STATUS_PRIORITY: Record<RuntimeWorktreeStatus, number> = {
|
||
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 RESOLVED_WORKTREE_CACHE_TTL_MS = 1000
|
||
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
|
||
const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000
|
||
// Why (§3.3): 30s freshness window. A second worktree-create or dispatch-probe
|
||
// against the same repo+remote within this window reuses the previous successful
|
||
// fetch instead of repeating the round-trip. Chosen so rapid "new worktree"
|
||
// clicks and successive coordinator dispatches feel snappy, while still being
|
||
// short enough that a genuinely-changed remote is observed on the next action.
|
||
const FETCH_FRESHNESS_MS = 30_000
|
||
const DRIFT_PROBE_SUBJECT_LIMIT = 5
|
||
|
||
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<T>(promise: Promise<T>, timeoutMs: number, fallback: T): Promise<T> {
|
||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||
return new Promise<T>((resolve) => {
|
||
timeout = setTimeout(() => resolve(fallback), timeoutMs)
|
||
promise.then(
|
||
(value) => resolve(value),
|
||
() => resolve(fallback)
|
||
)
|
||
}).finally(() => {
|
||
if (timeout) {
|
||
clearTimeout(timeout)
|
||
}
|
||
})
|
||
}
|
||
|
||
function withTimeoutResult<T>(
|
||
promise: Promise<T>,
|
||
timeoutMs: number
|
||
): Promise<{ ok: true; value: T } | { ok: false }> {
|
||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||
return new Promise<{ ok: true; value: T } | { ok: false }>((resolve) => {
|
||
timeout = setTimeout(() => resolve({ ok: false }), timeoutMs)
|
||
promise.then(
|
||
(value) => resolve({ ok: true, value }),
|
||
() => resolve({ ok: false })
|
||
)
|
||
}).finally(() => {
|
||
if (timeout) {
|
||
clearTimeout(timeout)
|
||
}
|
||
})
|
||
}
|
||
|
||
function buildPreview(lines: string[], partialLine: string): string {
|
||
const previewLines = buildTailLines(lines, partialLine)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
.slice(-MAX_PREVIEW_LINES)
|
||
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 user-facing preview is intentionally short, but wait readiness
|
||
// needs the retained terminal tail so Codex headers are not truncated away.
|
||
return waitText.length > 0 ? waitText : preview
|
||
}
|
||
|
||
function appendNormalizedToTailBuffer(
|
||
previousLines: string[],
|
||
previousPartialLine: string,
|
||
normalizedChunk: string
|
||
): {
|
||
lines: string[]
|
||
partialLine: string
|
||
truncated: boolean
|
||
newCompleteLines: number
|
||
} {
|
||
if (normalizedChunk.length === 0) {
|
||
return {
|
||
lines: previousLines,
|
||
partialLine: previousPartialLine,
|
||
truncated: false,
|
||
newCompleteLines: 0
|
||
}
|
||
}
|
||
|
||
// Why: fullscreen TUIs often emit long, newline-free redraw streams. Keep the
|
||
// larger line transcript for pagination, but keep partial-line work bounded.
|
||
const previousPartialWasCapped = previousPartialLine.length > MAX_TAIL_PARTIAL_CHARS
|
||
const boundedPreviousPartialLine = previousPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
|
||
const pieces = `${boundedPreviousPartialLine}${normalizedChunk}`.split('\n')
|
||
const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '')
|
||
const retainedPartialLine = nextPartialLine.slice(-MAX_TAIL_PARTIAL_CHARS)
|
||
const newCompleteLines = pieces.length
|
||
let nextLines =
|
||
newCompleteLines > 0
|
||
? [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))]
|
||
: previousLines
|
||
let truncated = previousPartialWasCapped || nextPartialLine.length > MAX_TAIL_PARTIAL_CHARS
|
||
|
||
while (nextLines.length > MAX_TAIL_LINES) {
|
||
nextLines.shift()
|
||
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
|
||
while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) {
|
||
totalChars -= nextLines.shift()!.length
|
||
truncated = true
|
||
}
|
||
}
|
||
|
||
return {
|
||
lines: nextLines,
|
||
partialLine: retainedPartialLine,
|
||
truncated,
|
||
newCompleteLines
|
||
}
|
||
}
|
||
|
||
function tailStateMatches(
|
||
lines: string[],
|
||
partialLine: string,
|
||
truncated: boolean,
|
||
linesTotal: number,
|
||
snapshot: {
|
||
lines: string[]
|
||
partialLine: string
|
||
truncated: boolean
|
||
linesTotal: number
|
||
}
|
||
): boolean {
|
||
if (
|
||
partialLine !== snapshot.partialLine ||
|
||
truncated !== snapshot.truncated ||
|
||
linesTotal !== snapshot.linesTotal ||
|
||
lines.length !== snapshot.lines.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
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
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 }
|
||
}
|
||
|
||
const tail = [...lines]
|
||
let omittedLineCount = 0
|
||
while (tail.length > 0 && totalCharacters - tail[0].length >= characterBudget) {
|
||
totalCharacters -= tail.shift()!.length
|
||
omittedLineCount += 1
|
||
}
|
||
|
||
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
|
||
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 are transcript/pagination reads. They return completed
|
||
// lines only so a partial line is not delivered once as "hel" and again as
|
||
// "hello" after the newline arrives.
|
||
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 for humans/agents. Return the
|
||
// latest bounded view, while the larger retained buffer remains available
|
||
// through cursor reads plus --limit.
|
||
const limit = terminalReadLimit(args.limit, DEFAULT_TERMINAL_READ_LIMIT)
|
||
const allLines = buildTailLines(args.completedLines, 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 unterminated partial line can exceed the preview character
|
||
// budget, but cursor reads only page completed lines, so the trimmed bytes
|
||
// cannot be recovered by asking for nextCursor again.
|
||
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 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
|
||
}
|
||
|
||
// Why: tui-idle relies on recognized agent CLIs setting OSC titles. If the
|
||
// terminal runs an unsupported CLI (or a plain shell), no title transition
|
||
// will ever fire. A 5-minute ceiling prevents indefinite hangs while still
|
||
// giving real agent tasks plenty of time to complete.
|
||
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 range for the user-facing mobileAutoRestoreFitMs preference.
|
||
// MIN floor: a couple of seconds is the smallest useful auto-restore
|
||
// (anything tighter is the legacy 300ms debounce).
|
||
// MAX ceiling: one hour — a held PTY beyond that is almost certainly
|
||
// "I forgot" rather than 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: user-supplied launch titles like "Codex YOLO" contain an agent name
|
||
// but are not 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 isCodexReadyPromptPreview(preview: string): boolean {
|
||
const normalized = preview.toLowerCase()
|
||
const readyIndex = findCodexReadyPromptIndex(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()
|
||
const blockedSignal = findTerminalWaitBlockedSignal(normalized)
|
||
if (blockedSignal === null) {
|
||
return null
|
||
}
|
||
const readyIndex = findCodexReadyPromptIndex(normalized)
|
||
// Why: retained terminal tails can include stale startup modals. If Codex's
|
||
// ready header appears after that modal, the latest signal is ready.
|
||
if (readyIndex !== null && readyIndex > blockedSignal.index) {
|
||
return null
|
||
}
|
||
return blockedSignal.reason
|
||
}
|
||
|
||
function findCodexReadyPromptIndex(normalized: string): number | null {
|
||
const headerIndex = normalized.lastIndexOf('openai codex')
|
||
if (headerIndex === -1) {
|
||
return null
|
||
}
|
||
const readySegment = normalized.slice(headerIndex)
|
||
// Why: current 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 findTerminalWaitBlockedSignal(
|
||
normalized: string
|
||
): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null {
|
||
const updateIndex = normalized.lastIndexOf('update available')
|
||
if (updateIndex !== -1 && normalized.includes('press enter to continue', updateIndex)) {
|
||
return { reason: 'codex-update-prompt', index: updateIndex }
|
||
}
|
||
const cwdIndex = normalized.lastIndexOf('choose working directory to')
|
||
if (cwdIndex !== -1 && normalized.includes('press enter to continue', cwdIndex)) {
|
||
return { 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)
|
||
) {
|
||
return { reason: 'codex-model-migration-prompt', index: modelMigrationIndex }
|
||
}
|
||
const hooksIndex = normalized.lastIndexOf('hooks need review')
|
||
if (hooksIndex !== -1 && normalized.includes('press enter to confirm', hooksIndex)) {
|
||
return { 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'))
|
||
) {
|
||
return { 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) {
|
||
return { reason: 'codex-interactive-prompt', index: interactivePromptIndex }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function buildTerminalWaitResult(
|
||
handle: string,
|
||
condition: RuntimeTerminalWaitCondition,
|
||
leaf: RuntimeLeafRecord
|
||
): RuntimeTerminalWait {
|
||
return {
|
||
handle,
|
||
condition,
|
||
satisfied: true,
|
||
status: getTerminalState(leaf),
|
||
exitCode: leaf.lastExitCode
|
||
}
|
||
}
|
||
|
||
function buildTerminalWaitBlockedResult(
|
||
handle: string,
|
||
condition: RuntimeTerminalWaitCondition,
|
||
leaf: RuntimeLeafRecord,
|
||
blockedReason: RuntimeTerminalWaitBlockedReason
|
||
): RuntimeTerminalWait {
|
||
return {
|
||
handle,
|
||
condition,
|
||
satisfied: false,
|
||
status: getTerminalState(leaf),
|
||
exitCode: leaf.lastExitCode,
|
||
blockedReason
|
||
}
|
||
}
|
||
|
||
function buildPtyTerminalWaitResult(
|
||
handle: string,
|
||
condition: RuntimeTerminalWaitCondition,
|
||
pty: RuntimePtyWorktreeRecord
|
||
): RuntimeTerminalWait {
|
||
return {
|
||
handle,
|
||
condition,
|
||
satisfied: true,
|
||
status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown',
|
||
exitCode: pty.lastExitCode
|
||
}
|
||
}
|
||
|
||
function buildPtyTerminalWaitBlockedResult(
|
||
handle: string,
|
||
condition: RuntimeTerminalWaitCondition,
|
||
pty: RuntimePtyWorktreeRecord,
|
||
blockedReason: RuntimeTerminalWaitBlockedReason
|
||
): RuntimeTerminalWait {
|
||
return {
|
||
handle,
|
||
condition,
|
||
satisfied: false,
|
||
status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown',
|
||
exitCode: pty.lastExitCode,
|
||
blockedReason
|
||
}
|
||
}
|
||
|
||
function branchSelectorMatches(branch: string, selector: string): boolean {
|
||
// Why: Git worktree data can report local branches as either `refs/heads/foo`
|
||
// or `foo` depending on which plumbing path produced the record. Orca's
|
||
// branch selectors should accept either form so newly created worktrees stay
|
||
// discoverable without exposing internal ref-shape differences to users.
|
||
return normalizeBranchRef(branch) === normalizeBranchRef(selector)
|
||
}
|
||
|
||
function runtimePathsEqual(left: string, right: string): boolean {
|
||
return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
|
||
}
|
||
|
||
function normalizeBranchRef(branch: string): string {
|
||
return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch
|
||
}
|
||
|
||
function inferWorktreeIdFromPtyId(ptyId: string): string | null {
|
||
const separatorIndex = ptyId.lastIndexOf('@@')
|
||
if (separatorIndex <= 0) {
|
||
return null
|
||
}
|
||
const worktreeId = ptyId.slice(0, separatorIndex)
|
||
return parseRuntimeWorktreeId(worktreeId) ? worktreeId : null
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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 so worktree.ps mirrors what
|
||
// the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer
|
||
// the runtime-tracked OSC title (covers daemon-hosted terminals) over the
|
||
// renderer-pushed leaf.title and the tab title. Falling back to
|
||
// lastAgentStatus only when no title is available preserves a sensible
|
||
// signal for very fresh leaves before any title has been observed.
|
||
const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? ''
|
||
const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus
|
||
if (detected === 'permission') {
|
||
return 'permission'
|
||
}
|
||
if (detected === 'working') {
|
||
return 'working'
|
||
}
|
||
return leaf.ptyId ? 'active' : 'inactive'
|
||
}
|
||
|
||
function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus {
|
||
const detected = detectAgentStatusFromTitle(title)
|
||
if (detected === 'permission') {
|
||
return 'permission'
|
||
}
|
||
if (detected === 'working') {
|
||
return 'working'
|
||
}
|
||
return hasPty ? 'active' : 'inactive'
|
||
}
|
||
|
||
function mergeWorktreeStatus(
|
||
current: RuntimeWorktreeStatus,
|
||
next: RuntimeWorktreeStatus
|
||
): RuntimeWorktreeStatus {
|
||
return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current
|
||
}
|
||
|
||
function normalizeTerminalChunk(chunk: string): string {
|
||
// Why: most high-throughput PTY chunks are plain printable text. Avoid
|
||
// running every ANSI/OSC regex over megabytes that do not need normalization.
|
||
if (!terminalChunkNeedsNormalization(chunk)) {
|
||
return chunk
|
||
}
|
||
return chunk
|
||
.replace(/\r\n/g, '\n')
|
||
.replace(/\r/g, '\n')
|
||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||
.replace(/\x1b[@-_]/g, '')
|
||
.replace(/\u0008/g, '')
|
||
.replace(/[^\x09\x0a\x20-\x7e]/g, '')
|
||
}
|
||
|
||
function terminalChunkNeedsNormalization(chunk: string): boolean {
|
||
for (let index = 0; index < chunk.length; index++) {
|
||
const code = chunk.charCodeAt(index)
|
||
if (
|
||
code === 0x1b ||
|
||
code === 0x0d ||
|
||
code < 0x09 ||
|
||
(code > 0x0a && code < 0x20) ||
|
||
code > 0x7e
|
||
) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
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
|
||
}
|
||
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)
|
||
}
|