Fix remote runtime terminal sharing with mobile (#4901)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-06-08 14:26:07 -07:00
committed by GitHub
co-authored by Orca
parent 90ac74d9b9
commit f5efdd5945
14 changed files with 1133 additions and 119 deletions
+204 -23
View File
@@ -10,7 +10,8 @@ import type { AgentStatus } from '../../shared/agent-detection'
import {
AGENT_STATUS_STALE_AFTER_MS,
type ParsedAgentStatusPayload,
type AgentStatusOrchestrationContext
type AgentStatusOrchestrationContext,
type AgentStatusEntry
} from '../../shared/agent-status-types'
import {
createAgentStatusOscProcessor,
@@ -2322,6 +2323,96 @@ export class OrcaRuntimeService {
return nextGroups
}
private publishPtyBackedMobileSessionTerminal(
worktreeId: string,
pty: RuntimePtyWorktreeRecord,
args: { tabId: string; leafId: string; title: string | null; activate: boolean }
): void {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const title = args.title ?? pty.title ?? pty.lastOscTitle ?? 'Terminal'
const existingTab = existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' &&
candidate.parentTabId === args.tabId &&
candidate.leafId === args.leafId
)
const parentLayout = this.buildMaterializedHeadlessParentLayout(
args.leafId,
pty.ptyId,
existingTab?.parentLayout
)
const tab: RuntimeMobileSessionTerminalTab = {
type: 'terminal',
id: `${args.tabId}::${args.leafId}`,
parentTabId: args.tabId,
leafId: args.leafId,
ptyId: pty.ptyId,
title,
parentLayout,
isActive: args.activate || existing?.activeTabId == null
}
const existingTabs = (existing?.tabs ?? []).filter(
(candidate) =>
!(
candidate.type === 'terminal' &&
candidate.parentTabId === args.tabId &&
candidate.leafId === args.leafId
)
)
const tabs = this.mergeMobileSessionSnapshotTabs(
existingTabs.map((candidate) => ({
...candidate,
isActive: tab.isActive ? false : candidate.isActive
})),
[tab]
)
const activeTab =
(tab.isActive ? tab : tabs.find((candidate) => candidate.id === existing?.activeTabId)) ??
tabs.find((candidate) => candidate.isActive) ??
tabs[0] ??
null
const terminalTabs = tabs.filter(
(candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal'
)
const next: RuntimeMobileSessionTabsSnapshot = {
worktree: worktreeId,
publicationEpoch:
existing?.publicationEpoch ?? `headless:pty-backed:${Date.now().toString(36)}`,
snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
activeGroupId: existing?.activeGroupId ?? this.getHeadlessMobileSessionGroupId(worktreeId),
activeTabId: activeTab?.id ?? null,
activeTabType: activeTab?.type ?? null,
tabGroups: this.mergeMobileSessionTabGroups(
worktreeId,
existing?.tabGroups ?? [],
terminalTabs,
activeTab?.type === 'terminal' ? activeTab : null
),
...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}),
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.notifyMobileSessionTabsChanged(worktreeId)
}
private touchMobileSessionSnapshotsForPty(ptyId: string): void {
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
const hasPtyBackedTab = snapshot.tabs.some(
(tab) =>
tab.type === 'terminal' &&
(tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId)
)
if (!hasPtyBackedTab) {
continue
}
this.mobileSessionTabsByWorktree.set(worktreeId, {
...snapshot,
snapshotVersion: snapshot.snapshotVersion + 1
})
this.notifyMobileSessionTabsChanged(worktreeId)
}
}
private buildHeadlessMobileSessionTerminalTabs(
worktreeId: string,
persistedTabs: readonly TerminalTab[]
@@ -3201,6 +3292,7 @@ export class OrcaRuntimeService {
return normalizedData
}
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
let shouldTouchPtyBackedSessionTabs = false
const ptyTailBefore = pty
? {
lines: pty.tailBuffer,
@@ -3227,8 +3319,11 @@ export class OrcaRuntimeService {
pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine)
if (oscTitle !== null) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
pty.lastOscTitle = oscTitle
pty.lastAgentStatus = agentStatus
shouldTouchPtyBackedSessionTabs =
prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
@@ -3308,6 +3403,9 @@ export class OrcaRuntimeService {
}
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
if (shouldTouchPtyBackedSessionTabs) {
this.touchMobileSessionSnapshotsForPty(ptyId)
}
const listeners = this.dataListeners.get(ptyId)
if (listeners) {
@@ -4519,6 +4617,7 @@ export class OrcaRuntimeService {
pty.lastExitCode = exitCode
this.resolvePtyExitWaiters(pty, ptyId)
this.pruneDisconnectedPtyTranscript(pty)
this.touchMobileSessionSnapshotsForPty(ptyId)
}
for (const leaf of this.getLeavesForPty(ptyId)) {
@@ -5641,13 +5740,15 @@ export class OrcaRuntimeService {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const graphEpoch = this.captureReadyGraphEpoch()
const graphEpoch = this.graphStatus === 'ready' ? this.rendererGraphEpoch : null
const targetWorktreeId = worktreeSelector
? (getExplicitWorktreeIdSelector(worktreeSelector) ??
(await this.resolveWorktreeSelector(worktreeSelector)).id)
: null
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
const resolvedWorktrees = [...worktreesById.values()]
await this.refreshPtyWorktreeRecordsFromController(resolvedWorktrees)
@@ -5661,17 +5762,19 @@ export class OrcaRuntimeService {
const terminals: RuntimeTerminalSummary[] = []
const ptyIdsFromLeaves = new Set<string>()
for (const leaf of this.leaves.values()) {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
if (graphEpoch !== null) {
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))
}
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
@@ -5697,6 +5800,32 @@ export class OrcaRuntimeService {
// 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> {
if (this.graphStatus !== 'ready') {
const targetWorktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: null
const snapshots = targetWorktreeId
? [this.getMobileSessionTabsForWorktree(targetWorktreeId)]
: await this.listAllMobileSessionTabs()
for (const snapshot of snapshots) {
const activeTerminal = snapshot.tabs.find(
(tab) =>
tab.type === 'terminal' &&
tab.isActive &&
tab.status === 'ready' &&
typeof tab.terminal === 'string'
)
if (activeTerminal?.type === 'terminal' && activeTerminal.terminal) {
return activeTerminal.terminal
}
}
const listed = await this.listTerminals(worktreeSelector)
const first = listed.terminals[0]?.handle
if (first) {
return first
}
throw new Error('no_active_terminal')
}
this.assertGraphReady()
const targetWorktreeId = worktreeSelector
@@ -5730,18 +5859,21 @@ export class OrcaRuntimeService {
}
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) {
const worktreesById = await this.getResolvedWorktreeMap()
return {
...this.buildPtyTerminalSummary(pty.pty, worktreesById),
tabId: pty.pty.tabId ?? pty.record.tabId,
leafId: parsePaneKey(pty.pty.paneKey ?? '')?.leafId ?? pty.record.leafId,
paneRuntimeId: -1,
ptyId: pty.pty.ptyId,
rendererGraphEpoch: this.rendererGraphEpoch
}
}
const graphEpoch = this.captureReadyGraphEpoch()
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
const { leaf } = this.getLiveLeafForHandle(handle)
const summary = this.buildTerminalSummary(leaf, worktreesById)
return {
@@ -10769,18 +10901,19 @@ export class OrcaRuntimeService {
}
async renameTerminal(handle: string, title: string | null): Promise<RuntimeTerminalRename> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
if (pty) {
pty.pty.title = title
this.touchMobileSessionSnapshotsForPty(pty.pty.ptyId)
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 }
return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, title }
}
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
this.notifier?.renameTerminal(leaf.tabId, title)
return { handle, tabId: leaf.tabId, title }
@@ -10889,6 +11022,14 @@ export class OrcaRuntimeService {
pty.paneKey = paneKey
}
const handle = pty ? this.issuePtyHandle(pty) : preAllocatedHandle
if (pty) {
this.publishPtyBackedMobileSessionTerminal(worktree.id, pty, {
tabId,
leafId,
title: opts.title ?? null,
activate: opts.activate === true
})
}
let surface: RuntimeTerminalCreate['surface'] = 'background'
if (this.notifier?.revealTerminalSession) {
try {
@@ -11357,7 +11498,6 @@ export class OrcaRuntimeService {
}
async focusTerminal(handle: string): Promise<RuntimeTerminalFocus> {
this.assertGraphReady()
const pty = this.getLivePtyForHandle(handle)
if (pty) {
if (!pty.pty.connected) {
@@ -11376,19 +11516,20 @@ export class OrcaRuntimeService {
worktreeId: pty.pty.worktreeId
}
}
this.assertGraphReady()
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)
this.claudeAgentTeams.removeTeamForLeaderHandle(handle)
if (pty) {
const ptyKilled = this.ptyController?.kill(pty.pty.ptyId) ?? false
return { handle, tabId: pty.record.tabId, ptyKilled }
return { handle, tabId: pty.pty.tabId ?? pty.record.tabId, ptyKilled }
}
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
let ptyKilled = false
if (leaf.ptyId) {
@@ -11513,6 +11654,14 @@ export class OrcaRuntimeService {
this.ptyController.kill?.(result.id)
throw error
}
if (createdPty) {
this.publishPtyBackedMobileSessionTerminal(worktree.id, createdPty, {
tabId: parentTabId,
leafId,
title: null,
activate: opts.activate !== false
})
}
return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 }
}
@@ -12959,10 +13108,12 @@ export class OrcaRuntimeService {
id: tab.id,
parentTabId: tab.parentTabId,
leafId: tab.leafId,
title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.title ?? tab.title,
title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.lastOscTitle ?? pty?.title ?? tab.title,
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
...(tab.agentStatus ? { agentStatus: tab.agentStatus } : {}),
...(tab.agentStatus
? { agentStatus: tab.agentStatus }
: this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
isActive: tab.isActive,
...(terminalHandle
@@ -13009,6 +13160,36 @@ export class OrcaRuntimeService {
}
}
private buildPtyMobileAgentStatus(
pty: RuntimePtyWorktreeRecord | null,
tab: RuntimeMobileSessionTerminalTab,
terminalHandle: string | null
): { agentStatus: AgentStatusEntry } | Record<string, never> {
if (!pty?.lastAgentStatus) {
return {}
}
const now = pty.lastOutputAt ?? Date.now()
return {
agentStatus: {
state:
pty.lastAgentStatus === 'working'
? 'working'
: pty.lastAgentStatus === 'permission'
? 'blocked'
: 'done',
prompt: '',
updatedAt: now,
stateStartedAt: now,
paneKey: this.getMobileTerminalPaneKey(tab),
...(terminalHandle ? { terminalHandle } : {}),
worktreeId: pty.worktreeId,
tabId: tab.parentTabId,
terminalTitle: pty.lastOscTitle ?? pty.title ?? tab.title,
stateHistory: []
}
}
}
private findPtyForMobileTerminalTab(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab,