mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
Fix remote runtime terminal sharing with mobile (#4901)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user