fix: propagate hook-only agent status to Remote Orca Server clients (#7970) (#7998)

* fix: propagate hook-only agent status to Remote Orca Server clients

On a headless Remote Orca Server, agent-status hooks (OSC 9999) updated the
retained row map but never republished PTY-backed session snapshots — only
terminal *title* changes did. Paired desktop/web/mobile clients therefore
kept a stale agent state (e.g. opencode working/idle) until relaunch, and
even title-driven updates carried an empty prompt and no agent identity
because the snapshot builder only used the title heuristic (#7970).

- retainAgentRowSnapshot reports client-visible changes (state, prompt,
  agent type, tool, interactive prompt, interrupted) so handlePtyData can
  republish snapshots on hook-only transitions without fanning out a
  rebuild per repeated same-state hook ping.
- buildPtyMobileAgentStatus prefers the fresh retained hook payload over
  the title-only fallback, so clients see the real state/prompt/agentType
  and interactive prompts. The non-agent-title suppression (#1437 stuck
  spinners) still wins unless the hook shows a live tool/question signal,
  and it now also covers leaf-backed panes with no PTY record.

Co-authored-by: Orca <help@stably.ai>

* fix: refetch remote projects when the client-events stream replays

worktreesChanged/reposChanged emitted during a transport gap are lost, not
queued. A quick drop can replay without flipping the environment
unreachable, so the reachability-transition refetch never runs and a
server-created worktree stays invisible until relaunch (#7970). Request a
debounced project refresh on the replay tag, mirroring the SSH-state
refetch that already rides it.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-09 19:21:55 -07:00 committed by GitHub
parent 4baaca601a
commit e8c84bb704
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 240 additions and 22 deletions

View File

@ -11326,6 +11326,121 @@ describe('OrcaRuntimeService', () => {
unsubscribe()
})
// #7970: on a headless serve there is no renderer syncing tab.agentStatus, so
// hook-only transitions must republish the PTY-backed snapshot themselves and
// the snapshot must carry the retained hook payload, not just title evidence.
it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-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))
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'hook-tab',
leafId: HEADLESS_LEAF_ID
})
events.length = 0
runtime.onPtyData(
'hook-only-pty',
'\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"opencode"}\x07',
100
)
expect(events).toHaveLength(1)
expect(events[0]?.tabs[0]).toEqual(
expect.objectContaining({
type: 'terminal',
agentStatus: expect.objectContaining({
state: 'working',
prompt: 'fix the tests',
agentType: 'opencode'
})
})
)
runtime.onPtyData(
'hook-only-pty',
'\x1b]9999;{"state":"waiting","prompt":"fix the tests","agentType":"opencode"}\x07',
101
)
expect(events).toHaveLength(2)
expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual(
expect.objectContaining({ state: 'waiting' })
)
unsubscribe()
})
it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-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))
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'hook-ping-tab',
leafId: HEADLESS_LEAF_ID
})
events.length = 0
const payload = '\x1b]9999;{"state":"working","prompt":"same","agentType":"codex"}\x07'
runtime.onPtyData('hook-ping-pty', payload, 100)
runtime.onPtyData('hook-ping-pty', payload, 101)
runtime.onPtyData('hook-ping-pty', payload, 102)
expect(events).toHaveLength(1)
unsubscribe()
})
it('suppresses a retained hook working status once the shell owns the pane title again', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'hook-exit-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))
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'hook-exit-tab',
leafId: HEADLESS_LEAF_ID
})
events.length = 0
runtime.onPtyData(
'hook-exit-pty',
'\x1b]9999;{"state":"working","prompt":"long task","agentType":"codex"}\x07',
100
)
// Agent exits without a hook done event; the shell takes the title back.
// The stuck-spinner guard (#1437) must win over the retained hook row.
runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101)
const last = events.at(-1)?.tabs[0]
expect(last?.type).toBe('terminal')
expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy()
unsubscribe()
})
it('stores normalized Pi idle OSC titles that still classify as idle', async () => {
const runtime = new OrcaRuntimeService(store)
syncSinglePty(runtime)

View File

@ -5533,8 +5533,11 @@ export class OrcaRuntimeService {
}
}
this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
if (shouldTouchPtyBackedSessionTabs) {
const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk)
// Why: hook (OSC 9999) transitions often arrive without a title change, so
// headless-serve snapshots would never republish and paired remote clients
// kept the stale agent state until the next title change (#7970).
if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) {
this.touchMobileSessionSnapshotsForPty(ptyId)
}
@ -5631,12 +5634,14 @@ export class OrcaRuntimeService {
return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix'
}
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void {
/** Returns true when any retained agent-row snapshot changed in a
* client-visible way, so the caller can republish session snapshots. */
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean {
// Why: snapshot retention (for mobile worktree.ps) must run even when no
// renderer listener is attached, so we don't early-return on a missing
// onTerminalAgentStatus — only the per-target emit below is gated on it.
if (chunk.payloads.length === 0) {
return
return false
}
const targets = new Map<
string,
@ -5669,9 +5674,17 @@ export class OrcaRuntimeService {
connectionId
})
}
let retainedChanged = false
for (const payload of chunk.payloads) {
for (const target of targets.values()) {
this.retainAgentRowSnapshot(ptyId, target.paneKey, target.worktreeId, target.tabId, payload)
retainedChanged =
this.retainAgentRowSnapshot(
ptyId,
target.paneKey,
target.worktreeId,
target.tabId,
payload
) || retainedChanged
if (!this.onTerminalAgentStatus) {
continue
}
@ -5692,6 +5705,7 @@ export class OrcaRuntimeService {
}
}
}
return retainedChanged
}
private retainAgentRowSnapshot(
@ -5700,7 +5714,7 @@ export class OrcaRuntimeService {
worktreeId: string | undefined,
tabId: string | undefined,
payload: ParsedAgentStatusPayload
): void {
): boolean {
const now = Date.now()
const previous = this.latestAgentStatusByPaneKey.get(paneKey)
// Why: stateStartedAt must mark the transition into the current state, not
@ -5717,6 +5731,17 @@ export class OrcaRuntimeService {
stateStartedAt,
updatedAt: now
})
// Client-visible change detection: snapshot republish is gated on this so
// repeated same-state hook pings don't fan a rebuild out to every client.
return (
!previous ||
previous.payload.state !== payload.state ||
previous.payload.prompt !== payload.prompt ||
(previous.payload.agentType ?? null) !== (payload.agentType ?? null) ||
(previous.payload.toolName ?? null) !== (payload.toolName ?? null) ||
(previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) ||
(previous.payload.interrupted ?? false) !== (payload.interrupted ?? false)
)
}
private clearAgentRowSnapshotsForPty(ptyId: string): void {
@ -19320,45 +19345,117 @@ export class OrcaRuntimeService {
tab: RuntimeMobileSessionTerminalTab,
terminalHandle: string | null
): { agentStatus: AgentStatusEntry } | Record<string, never> {
if (!pty?.lastAgentStatus) {
const paneKey = this.getMobileTerminalPaneKey(tab)
const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab)
if (!pty?.lastAgentStatus && !retained) {
return {}
}
const ptyTitle = getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
const ptyTitle = pty
? getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
: leaf
? getLatestAgentCandidateTitle(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
{ title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }
)
: null
const ptyTitleClassification = classifyAgentTitle(ptyTitle)
if (ptyTitle !== null && ptyTitleClassification !== 'agent') {
return {}
// Why: a non-agent title means the shell owns the pane again (the agent
// exited or was replaced) — suppressing here is what clears stuck
// spinners (#1437). A live hook signal (question card / active tool) is
// authoritative agent activity even under a task-named title, so it
// survives the suppression, mirroring the renderer-synced branch above.
const hasLiveHookSignal =
retained?.payload.interactivePrompt != null || retained?.payload.toolName != null
if (!hasLiveHookSignal) {
return {}
}
}
const now = pty.lastOutputAt ?? Date.now()
const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null
const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null
const terminalTitle = normalizeCompatibleAgentTitleForOwner(
(pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
ownerAgent
)
// Why: hook (OSC 9999) payloads carry the real state, prompt, and agent
// identity; the title heuristic below is a fallback with none of that.
// Without this, headless-serve clients only ever saw title-derived rows
// and hook-only transitions (e.g. opencode waiting) never surfaced (#7970).
if (retained) {
return {
agentStatus: normalizeCompatibleAgentStatusEntryForOwner(
{
...retained.payload,
paneKey,
updatedAt: retained.updatedAt,
stateStartedAt: retained.stateStartedAt,
stateHistory: [],
...(terminalHandle ? { terminalHandle } : {}),
...((pty?.worktreeId ?? retained.worktreeId)
? { worktreeId: pty?.worktreeId ?? retained.worktreeId }
: {}),
tabId: tab.parentTabId,
terminalTitle
},
ownerAgent
)
}
}
const now = pty!.lastOutputAt ?? Date.now()
const agentType = ownerAgent ?? undefined
return {
agentStatus: {
state:
pty.lastAgentStatus === 'working'
pty!.lastAgentStatus === 'working'
? 'working'
: pty.lastAgentStatus === 'permission'
: pty!.lastAgentStatus === 'permission'
? 'blocked'
: 'done',
prompt: '',
updatedAt: now,
stateStartedAt: now,
paneKey: this.getMobileTerminalPaneKey(tab),
paneKey,
...(terminalHandle ? { terminalHandle } : {}),
...(agentType ? { agentType } : {}),
worktreeId: pty.worktreeId,
worktreeId: pty!.worktreeId,
tabId: tab.parentTabId,
terminalTitle: normalizeCompatibleAgentTitleForOwner(
getLatestPtyTitle(pty) ?? tab.title,
ownerAgent
),
terminalTitle,
stateHistory: []
}
}
}
/** The retained OSC 9999 hook row for this mobile tab, when fresh enough to
* trust. Looked up by pane identity first, then by PTY ownership because
* legacy `pane:N` leaf ids can drift from the hook-side pane key. */
private getFreshRetainedAgentStatusForMobileTab(
paneKey: string,
pty: RuntimePtyWorktreeRecord | null,
tab: RuntimeMobileSessionTerminalTab
): RuntimeAgentRowSnapshot | null {
let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null
if (!retained) {
const ptyId = pty?.ptyId ?? tab.ptyId ?? null
if (ptyId) {
for (const snapshot of this.latestAgentStatusByPaneKey.values()) {
if (snapshot.ptyId !== ptyId) {
continue
}
if (!retained || snapshot.updatedAt > retained.updatedAt) {
retained = snapshot
}
}
}
}
if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
return null
}
return retained
}
private findPtyForMobileTerminalTab(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab,

View File

@ -1033,6 +1033,12 @@ export function useIpcEvents(): void {
getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds,
subscribe: (environmentId, onEvent, onError) =>
subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => {
// Why: worktreesChanged/reposChanged during the transport gap are
// lost, not queued. A quick drop can replay without ever flipping the
// env unreachable, so the reachability-transition refetch never runs
// and a server-created worktree stays invisible until relaunch
// (#7970). The scheduler debounces, so this stays cheap.
runtimeProjectRefreshScheduler.request(environmentId)
if (isPairedWebClientWindow()) {
return
}