mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Fix remote runtime terminal sharing with mobile (#4901)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
OrcaRuntimeService,
|
||||
type RuntimeTerminalAgentStatusEvent
|
||||
} from './orca-runtime'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import {
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
@@ -5056,12 +5057,14 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
|
||||
|
||||
await expect(runtime.renameTerminal(handle, 'Worker')).resolves.toMatchObject({
|
||||
const renamed = await runtime.renameTerminal(handle, 'Worker')
|
||||
expect(renamed).toMatchObject({
|
||||
handle,
|
||||
tabId: 'pty:pty-bg',
|
||||
title: 'Worker'
|
||||
})
|
||||
expect(renamed.tabId).not.toContain(':')
|
||||
await expect(runtime.showTerminal(handle)).resolves.toMatchObject({
|
||||
tabId: renamed.tabId,
|
||||
title: 'Worker'
|
||||
})
|
||||
})
|
||||
@@ -7190,6 +7193,343 @@ describe('OrcaRuntimeService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes laptop-created remote runtime terminals to phone session tabs', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer-empty',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
command: "claude 'work on the issue'",
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
runtime.onPtyData('laptop-created-pty', '\x1b]0;Codex working\x07', Date.now())
|
||||
runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', Date.now())
|
||||
|
||||
const phoneTabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(laptopTerminal.surface).toBe('background')
|
||||
expect(phoneTabs.tabs).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
parentTabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
status: 'ready',
|
||||
terminal: laptopTerminal.handle,
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'working',
|
||||
paneKey: `laptop-tab:${HEADLESS_LEAF_ID}`,
|
||||
terminalHandle: laptopTerminal.handle
|
||||
})
|
||||
})
|
||||
])
|
||||
await expect(runtime.readTerminal(laptopTerminal.handle)).resolves.toMatchObject({
|
||||
tail: ['Claude is working...']
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces pending phone session tabs when a laptop-created remote PTY becomes live', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'renderer-pending',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: `laptop-tab::${HEADLESS_LEAF_ID}`,
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [{ id: 'group-1', activeTabId: 'laptop-tab', tabOrder: ['laptop-tab'] }],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `laptop-tab::${HEADLESS_LEAF_ID}`,
|
||||
parentTabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
title: 'Starting Claude',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
|
||||
const phoneTabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(phoneTabs.tabs).toHaveLength(1)
|
||||
expect(phoneTabs.tabs[0]).toMatchObject({
|
||||
type: 'terminal',
|
||||
id: `laptop-tab::${HEADLESS_LEAF_ID}`,
|
||||
status: 'ready',
|
||||
terminal: laptopTerminal.handle
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes laptop-created remote runtime split terminals to phone session tabs', async () => {
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'laptop-created-pty' })
|
||||
.mockResolvedValueOnce({ id: 'laptop-split-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
const split = await runtime.splitTerminal(laptopTerminal.handle, {
|
||||
direction: 'vertical'
|
||||
})
|
||||
|
||||
const phoneTabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const terminalTabs = phoneTabs.tabs.filter((tab) => tab.type === 'terminal')
|
||||
|
||||
expect(split.tabId).toBe('laptop-tab')
|
||||
expect(terminalTabs).toHaveLength(2)
|
||||
expect(terminalTabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
parentTabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
status: 'ready',
|
||||
terminal: laptopTerminal.handle
|
||||
}),
|
||||
expect.objectContaining({
|
||||
parentTabId: 'laptop-tab',
|
||||
status: 'ready',
|
||||
terminal: split.handle
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('pushes PTY-backed mobile session tab title and agent status changes to subscribers', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
events.length = 0
|
||||
|
||||
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123)
|
||||
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude waiting for permission\x07', 124)
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: 'Claude working',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'working',
|
||||
terminalHandle: laptopTerminal.handle
|
||||
})
|
||||
})
|
||||
]
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'blocked',
|
||||
terminalHandle: laptopTerminal.handle
|
||||
})
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
expect(events[1]!.snapshotVersion).toBeGreaterThan(events[0]!.snapshotVersion)
|
||||
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
events.length = 0
|
||||
|
||||
runtime.onPtyExit('laptop-created-pty', 0)
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
parentTabId: 'laptop-tab',
|
||||
status: 'pending-handle',
|
||||
terminal: null
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
await expect(runtime.readTerminal(laptopTerminal.handle)).resolves.toMatchObject({
|
||||
status: 'exited'
|
||||
})
|
||||
})
|
||||
|
||||
it('operates PTY-backed mobile session terminals without a renderer graph', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const kill = vi.fn(() => true)
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
|
||||
await expect(runtime.renameTerminal(laptopTerminal.handle, 'Shared Claude')).resolves.toEqual({
|
||||
handle: laptopTerminal.handle,
|
||||
tabId: 'laptop-tab',
|
||||
title: 'Shared Claude'
|
||||
})
|
||||
await expect(runtime.focusTerminal(laptopTerminal.handle)).resolves.toEqual({
|
||||
handle: laptopTerminal.handle,
|
||||
tabId: 'laptop-tab',
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
await expect(runtime.closeTerminal(laptopTerminal.handle)).resolves.toEqual({
|
||||
handle: laptopTerminal.handle,
|
||||
tabId: 'laptop-tab',
|
||||
ptyKilled: true
|
||||
})
|
||||
expect(kill).toHaveBeenCalledWith('laptop-created-pty')
|
||||
})
|
||||
|
||||
it('lists PTY-backed mobile session terminals without a renderer graph', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07hello\r\n', 123)
|
||||
|
||||
await expect(runtime.listTerminals(`id:${TEST_WORKTREE_ID}`)).resolves.toMatchObject({
|
||||
terminals: [
|
||||
expect.objectContaining({
|
||||
handle: laptopTerminal.handle,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude working',
|
||||
connected: true,
|
||||
preview: 'hello'
|
||||
})
|
||||
],
|
||||
totalCount: 1,
|
||||
truncated: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows and resolves active PTY-backed mobile session terminals without a renderer graph', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const laptopTerminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
activate: true
|
||||
})
|
||||
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07hello\r\n', 123)
|
||||
|
||||
await expect(runtime.resolveActiveTerminal(`id:${TEST_WORKTREE_ID}`)).resolves.toBe(
|
||||
laptopTerminal.handle
|
||||
)
|
||||
await expect(runtime.showTerminal(laptopTerminal.handle)).resolves.toMatchObject({
|
||||
handle: laptopTerminal.handle,
|
||||
tabId: 'laptop-tab',
|
||||
leafId: HEADLESS_LEAF_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Claude working',
|
||||
connected: true,
|
||||
ptyId: 'laptop-created-pty'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.syncWindowGraph(0, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -145,7 +145,12 @@ function waitForWsClose(ws: WebSocket): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
|
||||
type AuthenticatedMobileWs = {
|
||||
ws: WebSocket
|
||||
sharedKey: Uint8Array
|
||||
}
|
||||
|
||||
async function authenticateMobileWsSession(pairingUrl: string): Promise<AuthenticatedMobileWs> {
|
||||
const parsed = parsePairingCode(pairingUrl)
|
||||
expect(parsed).toBeTruthy()
|
||||
const ws = await connectWs(parsed!.endpoint)
|
||||
@@ -168,7 +173,83 @@ async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
|
||||
return ws
|
||||
return { ws, sharedKey }
|
||||
}
|
||||
|
||||
async function authenticateMobileWs(pairingUrl: string): Promise<WebSocket> {
|
||||
return (await authenticateMobileWsSession(pairingUrl)).ws
|
||||
}
|
||||
|
||||
function sendEncryptedWsRequest(
|
||||
session: AuthenticatedMobileWs,
|
||||
request: Record<string, unknown>
|
||||
): void {
|
||||
session.ws.send(encrypt(JSON.stringify(request), session.sharedKey))
|
||||
}
|
||||
|
||||
function createEncryptedWsResponseReader(session: AuthenticatedMobileWs): {
|
||||
next: (
|
||||
id: string,
|
||||
predicate?: (response: Record<string, unknown>) => boolean
|
||||
) => Promise<Record<string, unknown>>
|
||||
dispose: () => void
|
||||
} {
|
||||
type Waiter = {
|
||||
id: string
|
||||
predicate: (response: Record<string, unknown>) => boolean
|
||||
resolve: (response: Record<string, unknown>) => void
|
||||
}
|
||||
const queue: Record<string, unknown>[] = []
|
||||
const waiters: Waiter[] = []
|
||||
|
||||
const takeQueued = (
|
||||
id: string,
|
||||
predicate: (response: Record<string, unknown>) => boolean
|
||||
): Record<string, unknown> | null => {
|
||||
const index = queue.findIndex((response) => response.id === id && predicate(response))
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
const [response] = queue.splice(index, 1)
|
||||
return response ?? null
|
||||
}
|
||||
|
||||
const onMessage = (data: WebSocket.RawData): void => {
|
||||
const decrypted = decrypt(
|
||||
typeof data === 'string' ? data : data.toString('utf-8'),
|
||||
session.sharedKey
|
||||
)
|
||||
expect(decrypted).toBeTruthy()
|
||||
const response = JSON.parse(decrypted!) as Record<string, unknown>
|
||||
const waiterIndex = waiters.findIndex(
|
||||
(waiter) => response.id === waiter.id && waiter.predicate(response)
|
||||
)
|
||||
if (waiterIndex === -1) {
|
||||
queue.push(response)
|
||||
return
|
||||
}
|
||||
const [waiter] = waiters.splice(waiterIndex, 1)
|
||||
waiter?.resolve(response)
|
||||
}
|
||||
|
||||
session.ws.on('message', onMessage)
|
||||
|
||||
return {
|
||||
next: (id: string, predicate: (response: Record<string, unknown>) => boolean = () => true) => {
|
||||
const queued = takeQueued(id, predicate)
|
||||
if (queued) {
|
||||
return Promise.resolve(queued)
|
||||
}
|
||||
return new Promise<Record<string, unknown>>((resolve) => {
|
||||
waiters.push({ id, predicate, resolve })
|
||||
})
|
||||
},
|
||||
dispose: () => {
|
||||
session.ws.off('message', onMessage)
|
||||
waiters.length = 0
|
||||
queue.length = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeWebSocket extends EventEmitter {
|
||||
@@ -2149,6 +2230,248 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('mirrors laptop-created remote runtime terminals into phone session tabs over RPC', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
|
||||
|
||||
await server.start()
|
||||
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const endpoint = metadata!.transports[0]!.endpoint
|
||||
const authToken = metadata!.authToken
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const createResponse = await sendRequest(endpoint, {
|
||||
id: 'laptop_create',
|
||||
authToken,
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree: 'id:repo-1::/tmp/worktree-a',
|
||||
command: "claude 'work on the issue'",
|
||||
tabId: 'laptop-tab',
|
||||
leafId
|
||||
}
|
||||
})
|
||||
|
||||
expect(createResponse).toMatchObject({
|
||||
id: 'laptop_create',
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
worktreeId: 'repo-1::/tmp/worktree-a',
|
||||
surface: 'background'
|
||||
}
|
||||
}
|
||||
})
|
||||
runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 456)
|
||||
runtime.onPtyData('laptop-created-pty', 'Claude is working...\r\n', 456)
|
||||
|
||||
const listResponse = await sendRequest(endpoint, {
|
||||
id: 'phone_list',
|
||||
authToken,
|
||||
method: 'session.tabs.list',
|
||||
params: {
|
||||
worktree: 'id:repo-1::/tmp/worktree-a'
|
||||
}
|
||||
})
|
||||
|
||||
const terminal = (
|
||||
createResponse.result as {
|
||||
terminal: { handle: string }
|
||||
}
|
||||
).terminal
|
||||
expect(listResponse).toMatchObject({
|
||||
id: 'phone_list',
|
||||
ok: true,
|
||||
result: {
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `laptop-tab::${leafId}`,
|
||||
parentTabId: 'laptop-tab',
|
||||
leafId,
|
||||
status: 'ready',
|
||||
terminal: terminal.handle,
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
paneKey: `laptop-tab:${leafId}`,
|
||||
terminalHandle: terminal.handle
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const readResponse = await sendRequest(endpoint, {
|
||||
id: 'phone_read',
|
||||
authToken,
|
||||
method: 'terminal.read',
|
||||
params: {
|
||||
terminal: terminal.handle
|
||||
}
|
||||
})
|
||||
expect(readResponse).toMatchObject({
|
||||
id: 'phone_read',
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
tail: ['Claude is working...']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await server.stop()
|
||||
})
|
||||
|
||||
it('streams laptop-created runtime terminals to a paired phone WebSocket client', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const writes: string[] = []
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'paired-laptop-pty' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime,
|
||||
userDataPath,
|
||||
enableWebSocket: true,
|
||||
wsPort: 0
|
||||
})
|
||||
|
||||
await server.start()
|
||||
|
||||
const phoneOffer = server.createPairingOffer({
|
||||
address: '127.0.0.1',
|
||||
name: 'phone',
|
||||
scope: 'mobile'
|
||||
})
|
||||
expect(phoneOffer.available).toBe(true)
|
||||
if (!phoneOffer.available) {
|
||||
throw new Error('WebSocket pairing unavailable')
|
||||
}
|
||||
const phone = await authenticateMobileWsSession(phoneOffer.pairingUrl)
|
||||
const phoneResponses = createEncryptedWsResponseReader(phone)
|
||||
const metadata = readRuntimeMetadata(userDataPath)
|
||||
const laptopEndpoint = metadata!.transports[0]!.endpoint
|
||||
const laptopAuthToken = metadata!.authToken
|
||||
const worktree = 'id:repo-1::/tmp/worktree-a'
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
try {
|
||||
sendEncryptedWsRequest(phone, {
|
||||
id: 'phone_subscribe_tabs',
|
||||
method: 'session.tabs.subscribe',
|
||||
params: { worktree }
|
||||
})
|
||||
await expect(
|
||||
phoneResponses.next('phone_subscribe_tabs', (response) => {
|
||||
const result = response.result as { type?: string; tabs?: unknown[] } | undefined
|
||||
return result?.type === 'snapshot' && result.tabs?.length === 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
streaming: true
|
||||
})
|
||||
|
||||
const blockedUpdate = phoneResponses.next('phone_subscribe_tabs', (response) => {
|
||||
const result = response.result as { type?: string; tabs?: unknown[] } | undefined
|
||||
const tab = result?.tabs?.[0] as { agentStatus?: { state?: string } } | undefined
|
||||
return result?.type === 'updated' && tab?.agentStatus?.state === 'blocked'
|
||||
})
|
||||
const createResponse = await sendRequest(laptopEndpoint, {
|
||||
id: 'laptop_create',
|
||||
authToken: laptopAuthToken,
|
||||
method: 'terminal.create',
|
||||
params: {
|
||||
worktree,
|
||||
command: "claude 'work on the issue'",
|
||||
tabId: 'laptop-tab',
|
||||
leafId,
|
||||
activate: true
|
||||
}
|
||||
})
|
||||
const terminal = (
|
||||
createResponse.result as {
|
||||
terminal: { handle: string }
|
||||
}
|
||||
).terminal
|
||||
runtime.onPtyData('paired-laptop-pty', '\x1b]0;Claude waiting for permission\x07', 456)
|
||||
runtime.onPtyData('paired-laptop-pty', 'Need approval\r\n', 457)
|
||||
|
||||
await expect(blockedUpdate).resolves.toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: {
|
||||
type: 'updated',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `laptop-tab::${leafId}`,
|
||||
parentTabId: 'laptop-tab',
|
||||
leafId,
|
||||
status: 'ready',
|
||||
terminal: terminal.handle,
|
||||
agentStatus: {
|
||||
state: 'blocked',
|
||||
paneKey: `laptop-tab:${leafId}`,
|
||||
terminalHandle: terminal.handle
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
sendEncryptedWsRequest(phone, {
|
||||
id: 'phone_read',
|
||||
method: 'terminal.read',
|
||||
params: { terminal: terminal.handle }
|
||||
})
|
||||
await expect(phoneResponses.next('phone_read')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
terminal: {
|
||||
tail: ['Need approval']
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
sendEncryptedWsRequest(phone, {
|
||||
id: 'phone_send',
|
||||
method: 'terminal.send',
|
||||
params: {
|
||||
terminal: terminal.handle,
|
||||
text: 'approved'
|
||||
}
|
||||
})
|
||||
await expect(phoneResponses.next('phone_send')).resolves.toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
send: {
|
||||
accepted: true
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(writes).toEqual(['approved'])
|
||||
} finally {
|
||||
phoneResponses.dispose()
|
||||
phone.ws.close()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('serves worktree.ps from the runtime summary builder', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
|
||||
const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never)
|
||||
|
||||
Reference in New Issue
Block a user