Infer interrupted agent status from terminal input (#2263)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-19 15:19:38 -07:00
committed by GitHub
co-authored by Orca
parent e97b109aac
commit 7d71131bb4
35 changed files with 3721 additions and 69 deletions
+920 -1
View File
@@ -13,7 +13,7 @@ import {
} from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { AgentHookServer, _internals } from './server'
import { AgentHookServer, agentHookServer, _internals } from './server'
import {
AGENT_STATUS_MAX_FIELD_LENGTH,
parseAgentStatusPayload
@@ -69,6 +69,855 @@ afterEach(() => {
})
describe('AgentHookServer listener replay', () => {
it('applies inferred interrupts through the cached status lifecycle', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
expect(applied).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'done',
prompt: 'long task',
agentType: 'codex',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: PANE,
payload: expect.objectContaining({ state: 'done', interrupted: true })
})
)
} finally {
vi.useRealTimers()
}
})
it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'opencode' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'opencode',
intent: 'plain-escape',
inputCount: 2
})
).toBe(true)
vi.setSystemTime(1_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'done',
prompt: 'long task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
paneKey: PANE,
payload: expect.objectContaining({ state: 'done', interrupted: true })
})
)
} finally {
vi.useRealTimers()
}
})
it('rejects inferred interrupts when a same-millisecond prompt update changed the row', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'first task', agentType: 'codex' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'second task', agentType: 'codex' }
},
'conn-1'
)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'first task',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
expect(applied).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'second task',
agentType: 'codex'
})
])
} finally {
vi.useRealTimers()
}
})
it.each(['opencode', 'copilot'] as const)(
'rejects single plain Escape inference for %s',
(agentType) => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: agentType,
intent: 'plain-escape'
})
expect(applied).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'long task',
agentType
})
])
} finally {
vi.useRealTimers()
}
}
)
it.each(['opencode', 'copilot'] as const)(
'accepts double plain Escape inference for %s',
(agentType) => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: agentType,
intent: 'plain-escape',
inputCount: 2
})
expect(applied).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'long task',
agentType,
interrupted: true
})
])
} finally {
vi.useRealTimers()
}
}
)
it('rejects Ctrl+C inference for Droid', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'droid' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
const applied = server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'droid',
intent: 'ctrl-c'
})
expect(applied).toBe(false)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'long task',
agentType: 'droid'
})
])
} finally {
vi.useRealTimers()
}
})
it('does not let late same-turn working hooks resurrect an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'long task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(6_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'long task',
agentType: 'pi',
toolName: 'bash',
toolInput: '/bin/sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'long task',
agentType: 'pi',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('allows a new prompt after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'first task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'first task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'second task', agentType: 'pi' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'second task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('allows an immediate same-prompt retry after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'retryable task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'retryable task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('allows a same-prompt working hook after the stale suppression window', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'repeat task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'repeat task',
agentType: 'pi',
toolName: 'bash',
toolInput: '/bin/sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'repeat task',
agentType: 'pi',
receivedAt: 16_501,
stateStartedAt: 16_501
})
])
} finally {
vi.useRealTimers()
}
})
it('rejects malformed inferred interrupt requests without throwing', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
},
'conn-1'
)
const malformed: unknown[] = [
{
paneKey: 'tab-1:0',
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 1_000,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
},
{
paneKey: PANE,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 1_000,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'sigint'
},
{
paneKey: PANE,
baselineUpdatedAt: '1_000',
baselineStateStartedAt: 1_000,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
},
{
paneKey: PANE,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 1_000,
baselinePrompt: 123,
baselineAgentType: 'codex',
intent: 'ctrl-c'
}
]
for (const request of malformed) {
expect(() =>
server.inferInterrupt(request as Parameters<AgentHookServer['inferInterrupt']>[0])
).not.toThrow()
expect(
server.inferInterrupt(request as Parameters<AgentHookServer['inferInterrupt']>[0])
).toBe(false)
}
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'long task',
agentType: 'codex'
})
])
} finally {
vi.useRealTimers()
}
})
it('allows an immediate same-prompt retry that carries cached turn detail', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'retryable task',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer',
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('suppresses replayed same-prompt working events after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'retryable task',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(20_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
isReplay: true,
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'retryable task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('matches renderer unknown sentinel to an omitted hook agent type', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'custom hook' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'custom hook',
baselineAgentType: 'unknown',
intent: 'ctrl-c'
})
).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'custom hook',
interrupted: true
})
])
} finally {
vi.useRealTimers()
}
})
it('rejects inferred interrupts for stale and non-working rows', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'waiting', prompt: 'permission', agentType: 'codex' }
},
'conn-1'
)
const waiting = server.getStatusSnapshot()[0]
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: waiting.receivedAt,
baselineStateStartedAt: waiting.stateStartedAt,
baselinePrompt: 'permission',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
).toBe(false)
server.ingestRemote(
{
paneKey: FRESH_PANE,
tabId: 'tab-fresh',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'old task', agentType: 'codex' }
},
'conn-1'
)
const stale = server.getStatusSnapshot().find((entry) => entry.paneKey === FRESH_PANE)!
vi.setSystemTime(stale.receivedAt + 30 * 60 * 1000 + 1)
expect(
server.inferInterrupt({
paneKey: FRESH_PANE,
baselineUpdatedAt: stale.receivedAt,
baselineStateStartedAt: stale.stateStartedAt,
baselinePrompt: 'old task',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('applies inferred interrupts for arbitrary agent types and Ctrl+C intent', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: GOOD_PANE,
tabId: 'tab-good',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'custom task', agentType: 'custom-agent' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot().find((entry) => entry.paneKey === GOOD_PANE)!
vi.setSystemTime(1_250)
expect(
server.inferInterrupt({
paneKey: GOOD_PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'custom task',
baselineAgentType: 'custom-agent',
intent: 'ctrl-c'
})
).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: GOOD_PANE,
state: 'done',
prompt: 'custom task',
agentType: 'custom-agent',
interrupted: true
})
])
} finally {
vi.useRealTimers()
}
})
it('allows multiple status-change subscribers to observe the same update', () => {
const server = new AgentHookServer()
const first = vi.fn()
@@ -1242,6 +2091,76 @@ describe('Cursor hook normalization', () => {
expect(result?.payload.lastAssistantMessage).toBe('Done — wrote the README.')
})
it('late afterAgentResponse after stop keeps Cursor done instead of resurrecting working', () => {
const submit = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }),
'production'
)
expect(submit).not.toBeNull()
if (!submit) {
throw new Error('expected Cursor beforeSubmitPrompt to normalize')
}
agentHookServer.ingestRemote(
{
paneKey: submit.paneKey,
tabId: submit.tabId,
worktreeId: submit.worktreeId,
payload: submit.payload
},
'conn-1'
)
const stop = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'stop', status: 'completed' }),
'production'
)
expect(stop).not.toBeNull()
if (!stop) {
throw new Error('expected Cursor stop to normalize')
}
agentHookServer.ingestRemote(
{
paneKey: stop.paneKey,
tabId: stop.tabId,
worktreeId: stop.worktreeId,
payload: stop.payload
},
'conn-1'
)
const response = _internals.normalizeHookPayload(
'cursor',
buildBody({ hook_event_name: 'afterAgentResponse', text: 'All set.' }),
'production'
)
expect(response?.payload.state).toBe('done')
expect(response?.payload.lastAssistantMessage).toBe('All set.')
if (!response) {
throw new Error('expected Cursor afterAgentResponse to normalize')
}
agentHookServer.ingestRemote(
{
paneKey: response.paneKey,
tabId: response.tabId,
worktreeId: response.worktreeId,
payload: response.payload
},
'conn-1'
)
expect(agentHookServer.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'done',
agentType: 'cursor',
prompt: 'add tests',
lastAssistantMessage: 'All set.'
})
])
})
it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => {
_internals.normalizeHookPayload(
'cursor',
+112
View File
@@ -36,10 +36,16 @@ import {
} from '../../shared/agent-hook-listener'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusIpcPayload,
type AgentType,
type AgentStatusState,
normalizeAgentStatusPayload
} from '../../shared/agent-status-types'
import {
isAgentInterruptInputIntent,
type AgentInterruptInferenceRequest
} from '../../shared/agent-interrupt-intent'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
@@ -78,6 +84,7 @@ type PaneKeyAliasEntry = {
const LAST_STATUS_FILE_NAME = 'last-status.json'
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
const ASSISTANT_MESSAGE_RETRY_MS = 50
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
@@ -106,6 +113,15 @@ type LastStatusFile = {
entries: Record<string, EnrichedAgentHookEventPayload>
}
function equivalentInterruptAgentType(
actual: AgentType | undefined,
baseline: AgentType | undefined
): boolean {
const normalizedActual = actual === 'unknown' ? undefined : actual
const normalizedBaseline = baseline === 'unknown' ? undefined : baseline
return normalizedActual === normalizedBaseline
}
// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
// at write/hydrate time so legacy numeric rows fail closed.
export function isValidPaneKey(value: unknown): value is string {
@@ -173,6 +189,7 @@ function sanitizeHydratedEntry(
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined,
payload,
receivedAt,
stateStartedAt
@@ -279,6 +296,70 @@ export class AgentHookServer {
)
}
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
if (!isValidPaneKey(request.paneKey)) {
return false
}
if (!isAgentInterruptInputIntent(request.intent)) {
return false
}
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
if (!existing) {
return false
}
const payload = existing.payload
const agentType: AgentType | undefined = payload.agentType
// Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C
// exits the CLI, which is handled by process/PTY lifecycle cleanup.
if (agentType === 'droid' && request.intent === 'ctrl-c') {
return false
}
// Why: these agents use the first Escape as a TUI/editor cancel. A single
// Escape can leave the turn running, so only a deliberate double Escape
// may infer an interrupted turn.
if (
(agentType === 'opencode' || agentType === 'copilot') &&
request.intent === 'plain-escape' &&
request.inputCount !== 2
) {
return false
}
// Why: input-intent inference is a fallback for a missing final hook. A strict
// baseline match keeps a delayed timer from overwriting any newer hook,
// including same-millisecond prompt or agent identity changes.
if (
payload.state !== 'working' ||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
payload.prompt !== request.baselinePrompt ||
existing.receivedAt !== request.baselineUpdatedAt ||
existing.stateStartedAt !== request.baselineStateStartedAt ||
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
) {
return false
}
const inferred = this.applyNormalizedStatus({
paneKey: existing.paneKey,
tabId: existing.tabId,
worktreeId: existing.worktreeId,
connectionId: existing.connectionId,
payload: {
state: 'done',
prompt: payload.prompt,
agentType,
interrupted: true
}
})
console.debug('[agent-hooks] inferred interrupted agent status', {
paneKey: inferred.paneKey,
agentType,
intent: request.intent
})
return true
}
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => {
const enriched = entry as EnrichedAgentHookEventPayload
@@ -319,6 +400,33 @@ export class AgentHookServer {
}
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
| EnrichedAgentHookEventPayload
| undefined
// Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
// stopped the turn. Do not let that stale same-turn event resurrect the row.
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
payload.payload.state === 'done' &&
previous.payload.agentType === payload.payload.agentType &&
previous.payload.prompt === payload.payload.prompt &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
) {
return previous
}
if (
previous?.payload.state === 'done' &&
previous.payload.interrupted === true &&
payload.payload.state === 'working' &&
previous.payload.agentType === payload.payload.agentType &&
previous.payload.prompt === payload.payload.prompt &&
(payload.isReplay === true ||
(payload.hasExplicitPrompt !== true &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
) {
return previous
}
if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(payload.paneKey)
}
@@ -517,6 +625,8 @@ export class AgentHookServer {
worktreeId?: string
env?: string
version?: string
hasExplicitPrompt?: boolean
isReplay?: boolean
payload: unknown
},
connectionId: string
@@ -592,6 +702,8 @@ export class AgentHookServer {
tabId,
worktreeId,
connectionId: trimmedConnectionId,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
payload: normalizedPayload
}
this.applyNormalizedStatus(event)
+4
View File
@@ -245,6 +245,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
}
hasPty(id: string): boolean {
return this.activeSessionIds.has(id)
}
write(id: string, data: string): void {
this.markSessionDirty(id)
this.sendNotification('write', { sessionId: id, data })
+15
View File
@@ -29,6 +29,7 @@ function createAdapter(
title: label
}))
),
hasPty: vi.fn((id: string) => sessions.includes(id)),
write: vi.fn((id: string, data: string) => {
writes.push({ id, data })
}),
@@ -120,6 +121,20 @@ describe('DaemonPtyRouter', () => {
expect(current.spawn).toHaveBeenCalledWith({ sessionId: 'legacy-session', cols: 80, rows: 24 })
})
it('uses mapped adapter liveness instead of routing-cache presence for hasPty', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy', ['legacy-session'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
expect(router.hasPty('legacy-session')).toBe(true)
await router.shutdown('legacy-session', { keepHistory: true })
expect(router.hasPty('legacy-session')).toBe(false)
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('merges startup reconciliation and updates route mappings', async () => {
const current = createAdapter('current', [], {
alive: ['current-alive'],
+8
View File
@@ -55,6 +55,14 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.adapterFor(id).attach(id)
}
hasPty(id: string): boolean {
const routed = this.sessionAdapters.get(id)
if (routed) {
return routed.hasPty(id)
}
return this.current.hasPty(id) || this.legacy.some((adapter) => adapter.hasPty(id))
}
write(id: string, data: string): void {
this.adapterFor(id).write(id, data)
}
+33
View File
@@ -197,6 +197,39 @@ describe('createPtySubprocess', () => {
expect(env.ORCA_WORKTREE_ID).toBe('child-worktree')
})
it('does not inherit parent agent hook endpoint for development hook env', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const previousEndpoint = process.env.ORCA_AGENT_HOOK_ENDPOINT
process.env.ORCA_AGENT_HOOK_ENDPOINT = '/tmp/stale-endpoint.env'
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
env: {
ORCA_AGENT_HOOK_ENV: 'development',
ORCA_AGENT_HOOK_PORT: '1234',
ORCA_AGENT_HOOK_TOKEN: 'token',
ORCA_AGENT_HOOK_VERSION: '1'
}
})
} finally {
if (previousEndpoint === undefined) {
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
} else {
process.env.ORCA_AGENT_HOOK_ENDPOINT = previousEndpoint
}
}
const env = spawnMock.mock.calls.at(-1)?.[2].env
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_ENV).toBe('development')
expect(env.ORCA_AGENT_HOOK_PORT).toBe('1234')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('token')
})
it('forwards write calls', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
+12
View File
@@ -63,6 +63,17 @@ function removeUnspecifiedPaneIdentityEnv(
}
}
function removeInheritedDevAgentHookEndpoint(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development') {
// Why: the daemon inherits the app process env before per-PTY env is
// merged, so dev terminals must explicitly drop a parent endpoint.env.
delete env.ORCA_AGENT_HOOK_ENDPOINT
}
}
function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError {
const detailName = kind === 'helper' ? 'helper' : 'cwd'
const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd'
@@ -165,6 +176,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// Why: the daemon is forked from Electron and can inherit the pane identity
// of the terminal that launched `pn dev`; each PTY must opt into its own.
removeUnspecifiedPaneIdentityEnv(env, opts.env)
removeInheritedDevAgentHookEndpoint(env, opts.env)
removeInheritedNoColor(env)
env.LANG ??= 'en_US.UTF-8'
+38 -1
View File
@@ -9,6 +9,7 @@ import { makePaneKey } from '../../shared/stable-pane-id'
const dropStatusEntry = vi.fn()
const getStatusSnapshot = vi.fn()
const inferInterrupt = vi.fn()
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>()
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
const removeHandler = vi.fn()
@@ -36,7 +37,8 @@ vi.mock('../agent-hooks/server', async () => {
...actual,
agentHookServer: {
dropStatusEntry,
getStatusSnapshot
getStatusSnapshot,
inferInterrupt
}
}
})
@@ -66,6 +68,7 @@ vi.mock('../hermes/hook-service', () => ({
beforeEach(() => {
dropStatusEntry.mockReset()
getStatusSnapshot.mockReset()
inferInterrupt.mockReset()
onHandlers.clear()
handleHandlers.clear()
removeHandler.mockReset()
@@ -98,6 +101,40 @@ describe('agentStatus:getSnapshot IPC', () => {
})
})
describe('agentStatus:inferInterrupt IPC', () => {
it('forwards valid inference requests to the hook server', async () => {
inferInterrupt.mockReturnValue(true)
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:inferInterrupt')
expect(handler).toBeDefined()
const request = {
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
}
expect(handler!({}, request)).toBe(true)
expect(inferInterrupt).toHaveBeenCalledWith(request)
})
it('rejects malformed requests before the hook server boundary', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:inferInterrupt')
expect(handler).toBeDefined()
for (const value of [null, undefined, '', 123, true]) {
expect(handler!({}, value)).toBe(false)
}
expect(inferInterrupt).not.toHaveBeenCalled()
})
})
describe('agentStatus:drop IPC', () => {
it('forwards drop to dropStatusEntry', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
+8
View File
@@ -4,6 +4,7 @@ import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from '../../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
import {
clearMigrationUnsupportedPtysForPaneKey,
@@ -38,6 +39,7 @@ export function registerAgentHookHandlers(): void {
ipcMain.removeHandler('agentHooks:copilotStatus')
ipcMain.removeHandler('agentHooks:hermesStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
// ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't
@@ -64,6 +66,12 @@ export function registerAgentHookHandlers(): void {
// lose replayed statuses while its local store is still empty.
return agentHookServer.getStatusSnapshot()
})
ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => {
if (typeof request !== 'object' || request === null) {
return false
}
return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest)
})
ipcMain.handle(
'agentStatus:getMigrationUnsupportedSnapshot',
(): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot()
+38
View File
@@ -192,6 +192,7 @@ describe('registerPtyHandlers', () => {
delete process.env.OPENCODE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
delete process.env.ORCA_OPENCODE_CONFIG_DIR
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
delete process.env.PI_CODING_AGENT_DIR
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
delete process.env.ORCA_PI_CODING_AGENT_DIR
@@ -805,6 +806,23 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
})
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
const { app } = await import('electron')
const mockedApp = app as unknown as { isPackaged: boolean }
const prev = mockedApp.isPackaged
mockedApp.isPackaged = false
try {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
})
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
} finally {
mockedApp.isPackaged = prev
}
})
it('prepends attribution shims on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
enableGitHubAttribution: true
@@ -2622,6 +2640,26 @@ describe('registerPtyHandlers', () => {
}
})
it('acknowledges pty writes only for owned PTYs', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(handlers.get('pty:writeAccepted')!(null, { id: result.id, data: '\x03' })).toBe(true)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x03')
expect(
handlers.get('pty:writeAccepted')!(null, {
id: 'missing-pty-for-write-ack',
data: '\x03'
})
).toBe(false)
expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
})
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
+45 -5
View File
@@ -313,6 +313,9 @@ export function buildPtyHostEnv(
// must inject the loopback receiver coordinates before the agent starts.
// Without these env vars the global hook config cannot map callbacks back
// to the correct Orca pane.
// Why: nested Orca terminals can inherit another process's hook endpoint or
// token. Strip all hook runtime coordinates before injecting this PTY's fresh
// server values so callbacks route to the owning app/runtime.
for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) {
delete baseEnv[key]
}
@@ -583,6 +586,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
ipcMain.removeHandler('pty:writeAccepted')
ipcMain.removeAllListeners('pty:write')
ipcMain.removeAllListeners('pty:ackColdRestore')
ipcMain.removeAllListeners('pty:serializeBuffer:response')
@@ -1607,7 +1611,7 @@ export function registerPtyHandlers(
}
)
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
const writePtyInput = (args: { id: string; data: string }): boolean => {
// Why: defense-in-depth for the mobile-presence lock. The renderer's
// xterm.onData guard already drops desktop keystrokes when mobile is
// driving, but a stale view between the main-side state flip and the
@@ -1615,14 +1619,50 @@ export function registerPtyHandlers(
// This server-side check catches it. See
// docs/mobile-presence-lock.md.
if (runtime?.getDriver(args.id).kind === 'mobile') {
return
return false
}
const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined
if (!provider) {
return
return false
}
lastInputAtByPty.set(args.id, performance.now())
provider.write(args.id, args.data)
try {
lastInputAtByPty.set(args.id, performance.now())
provider.write(args.id, args.data)
return true
} catch {
return false
}
}
const writePtyInputAccepted = (args: { id: string; data: string }): boolean => {
if (runtime?.getDriver(args.id).kind === 'mobile') {
return false
}
// Why: the acknowledgement is used to infer Ctrl+C/Escape actually reached
// the local PTY. SSH providers are fire-and-forget relay notifications, so
// they cannot truthfully acknowledge until the relay protocol grows a write
// request/response.
if (ptyOwnership.get(args.id) !== null) {
return false
}
const provider = tryGetProviderForPty(args.id)
if (!provider?.hasPty?.(args.id)) {
return false
}
try {
lastInputAtByPty.set(args.id, performance.now())
provider.write(args.id, args.data)
return true
} catch {
return false
}
}
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
writePtyInput(args)
})
ipcMain.handle('pty:writeAccepted', (_event, args: { id: string; data: string }): boolean => {
return writePtyInputAccepted(args)
})
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
+3
View File
@@ -440,6 +440,9 @@ export class LocalPtyProvider implements IPtyProvider {
// Local PTYs are always attached -- no-op. Remote providers use this to resubscribe.
async attach(_id: string): Promise<void> {}
hasPty(id: string): boolean {
return ptyProcesses.has(id)
}
write(id: string, data: string): void {
ptyProcesses.get(id)?.write(data)
}
+1
View File
@@ -83,6 +83,7 @@ export type PtySpawnResult = {
export type IPtyProvider = {
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
attach(id: string): Promise<void>
hasPty?: (id: string) => boolean
write(id: string, data: string): void
resize(id: string, cols: number, rows: number): void
shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void>
@@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: this integration spec keeps the SSH relay,
agent-hook server, and replay/interrupt ordering fixtures together so regressions
cover the full mux-to-main path. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
@@ -324,4 +327,120 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
expect(events).toHaveLength(0)
expect(agentHookServer.getStatusSnapshot()).toEqual([])
})
it('preserves explicit-prompt metadata from remote hook notifications', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
platform: 'linux-x64'
})
session = createSession('conn-explicit-prompt')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retry same prompt',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
})
)
await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), {
timeout: 1500
})
const first = agentHookServer.getStatusSnapshot()[0]
expect(
agentHookServer.inferInterrupt({
paneKey: first.paneKey,
baselineUpdatedAt: first.receivedAt,
baselineStateStartedAt: first.stateStartedAt,
baselinePrompt: 'retry same prompt',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retry same prompt',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
})
)
await vi.waitFor(() =>
expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'retry same prompt',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
})
)
})
it('preserves replay metadata from remote hook notifications', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
platform: 'linux-x64'
})
session = createSession('conn-replay-marker')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'replayed prompt',
agentType: 'opencode'
}
})
)
await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), {
timeout: 1500
})
const first = agentHookServer.getStatusSnapshot()[0]
expect(
agentHookServer.inferInterrupt({
paneKey: first.paneKey,
baselineUpdatedAt: first.receivedAt,
baselineStateStartedAt: first.stateStartedAt,
baselinePrompt: 'replayed prompt',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
isReplay: true,
payload: {
state: 'working',
prompt: 'replayed prompt',
agentType: 'opencode'
}
})
)
await new Promise((resolve) => setImmediate(resolve))
expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'replayed prompt',
agentType: 'opencode',
interrupted: true
})
})
})
+4
View File
@@ -583,6 +583,8 @@ export class SshRelaySession {
worktreeId?: unknown
env?: unknown
version?: unknown
hasExplicitPrompt?: unknown
isReplay?: unknown
payload?: unknown
}
if (typeof envelope.paneKey !== 'string') {
@@ -600,6 +602,8 @@ export class SshRelaySession {
worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined,
env: typeof envelope.env === 'string' ? envelope.env : undefined,
version: typeof envelope.version === 'string' ? envelope.version : undefined,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
payload: envelope.payload
},
this.targetId
+3
View File
@@ -161,6 +161,7 @@ import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@@ -708,6 +709,7 @@ export type PreloadApi = {
coldRestore?: { scrollback: string; cwd: string }
}>
write: (id: string, data: string) => void
writeAccepted: (id: string, data: string) => Promise<boolean>
resize: (id: string, cols: number, rows: number) => void
reportGeometry: (id: string, cols: number, rows: number) => void
signal: (id: string, signal: string) => void
@@ -1921,6 +1923,7 @@ export type PreloadApi = {
onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void
/** Return the current main-process hook cache after renderer hydration. */
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
/** Listen for PTYs that still use a legacy numeric pane key but have
* registry-backed UUID pane proof. */
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
+5
View File
@@ -103,6 +103,7 @@ import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type {
SpeechErrorEvent,
SpeechLifecycleEvent,
@@ -605,6 +606,8 @@ const api = {
write: (id: string, data: string): void => {
ipcRenderer.send('pty:write', { id, data })
},
writeAccepted: (id: string, data: string): Promise<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data }),
resize: (id: string, cols: number, rows: number): void => {
ipcRenderer.send('pty:resize', { id, cols, rows })
@@ -3051,6 +3054,8 @@ const api = {
* knows which tabs exist. */
getSnapshot: (): Promise<AgentStatusIpcPayload[]> =>
ipcRenderer.invoke('agentStatus:getSnapshot'),
inferInterrupt: (request: AgentInterruptInferenceRequest): Promise<boolean> =>
ipcRenderer.invoke('agentStatus:inferInterrupt', request),
onMigrationUnsupported: (
callback: (entry: MigrationUnsupportedPtyEntry) => void
): (() => void) => {
+1
View File
@@ -117,6 +117,7 @@ describe('RelayAgentHookServer', () => {
expect(forward.mock.calls[0][0].source).toBe('claude')
expect(forward.mock.calls[0][0].env).toBe('remote')
expect(forward.mock.calls[0][0].version).toBe('1')
expect(forward.mock.calls[0][0].isReplay).toBe(true)
} finally {
server.stop()
}
+5 -2
View File
@@ -176,7 +176,7 @@ export class RelayAgentHookServer {
if (!meta) {
continue
}
this.forwardEvent(event, meta.source, meta.env, meta.version)
this.forwardEvent(event, meta.source, meta.env, meta.version, { isReplay: true })
count++
}
return count
@@ -267,7 +267,8 @@ export class RelayAgentHookServer {
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string
version?: string,
options: { isReplay?: boolean } = {}
): void {
const envelope: AgentHookRelayEnvelope = {
source,
@@ -275,6 +276,8 @@ export class RelayAgentHookServer {
tabId: event.tabId,
worktreeId: event.worktreeId,
connectionId: null,
hasExplicitPrompt: event.hasExplicitPrompt,
isReplay: options.isReplay === true ? true : undefined,
env,
version,
payload: event.payload
@@ -0,0 +1,395 @@
/* eslint-disable max-lines -- Why: this suite locks the interrupt inference state machine across several agent-specific keyboard semantics. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import {
createAgentInterruptInference,
isCtrlCKeyEvent,
isPlainEscapeKeyEvent
} from './agent-interrupt-inference'
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'working',
prompt: 'write tests',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
paneKey: PANE_KEY,
terminalTitle: 'Codex',
stateHistory: [],
...overrides
}
}
function keyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: '',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false,
...overrides
} as KeyboardEvent
}
describe('agent interrupt inference', () => {
afterEach(() => {
vi.useRealTimers()
})
it.each([['ctrl-c', 'custom-agent']] as const)(
'emits a strict baseline request for %s from %s after the settle window',
(intent, agentType) => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent(intent)
vi.advanceTimersByTime(499)
expect(inferInterrupt).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: agentType,
intent
})
tracker.dispose()
entry = undefined
}
)
it.each([
['plain-escape', 'gemini'],
['ctrl-c', 'gemini']
] as const)(
'emits a strict baseline request for %s from Gemini immediately',
(intent, agentType) => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent(intent)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: agentType,
intent
})
tracker.dispose()
entry = undefined
}
)
it('emits when the working row has no agent type', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: undefined })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
vi.advanceTimersByTime(500)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: undefined,
intent: 'ctrl-c'
})
tracker.dispose()
entry = undefined
})
it('does not infer Ctrl+C for Droid', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'droid' })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
entry = undefined
})
it.each(['opencode', 'copilot'] as const)(
'infers immediately on double Escape for %s',
(agentType) => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.observeInputIntent('plain-escape')
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: agentType,
intent: 'plain-escape',
inputCount: 2
})
tracker.dispose()
entry = undefined
}
)
it('does not count an OpenCode Escape across a new turn', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
entry = makeEntry({ agentType: 'opencode', prompt: 'second task', stateStartedAt: 1_050 })
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
entry = undefined
})
it.each(['opencode', 'copilot'] as const)(
'does not count a %s Escape after the double-Escape window expires',
(agentType) => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
vi.advanceTimersByTime(500)
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
entry = undefined
}
)
it('does not emit again for a third OpenCode Escape after the row is already done', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn((request) => {
entry = makeEntry({
state: 'done',
agentType: request.baselineAgentType,
prompt: request.baselinePrompt,
updatedAt: 1_500,
stateStartedAt: 1_500
})
})
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
tracker.observeInputIntent('plain-escape')
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).toHaveBeenCalledTimes(1)
tracker.dispose()
entry = undefined
})
it('still infers Ctrl+C for OpenCode', () => {
vi.useFakeTimers()
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
vi.advanceTimersByTime(500)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
tracker.dispose()
entry = undefined
})
it('does not emit for non-working states', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry({ state: 'waiting', agentType: 'codex' })
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
vi.runOnlyPendingTimers()
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
})
it('cancels when a newer hook update arrives during the settle window', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
entry = makeEntry({ updatedAt: 1_001 })
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
})
it('cancels when a normal done hook arrives during the settle window', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
entry = makeEntry({ state: 'done', updatedAt: 1_050, stateStartedAt: 1_050 })
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
})
it('emits the captured baseline when the renderer status disappears during the settle window', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('plain-escape')
entry = undefined
vi.advanceTimersByTime(500)
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
tracker.dispose()
})
it('dispose cancels a pending inference timer', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => makeEntry(),
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
tracker.dispose()
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
})
it('requires exact plain Escape and Ctrl+C key events', () => {
expect(isPlainEscapeKeyEvent(keyEvent({ key: 'Escape' }))).toBe(true)
expect(isCtrlCKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(true)
expect(isCtrlCKeyEvent(keyEvent({ key: 'C', ctrlKey: true }))).toBe(true)
for (const event of [
keyEvent({ key: 'Escape', altKey: true }),
keyEvent({ key: 'Escape', shiftKey: true }),
keyEvent({ key: 'Escape', repeat: true }),
keyEvent({ key: 'c', ctrlKey: true, metaKey: true }),
keyEvent({ key: 'c', ctrlKey: true, repeat: true })
]) {
expect(isPlainEscapeKeyEvent(event) || isCtrlCKeyEvent(event)).toBe(false)
}
})
})
@@ -0,0 +1,242 @@
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import {
AGENT_INTERRUPT_SETTLE_MS,
type AgentInterruptInferenceRequest,
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
export type AgentInterruptInference = {
observeInputIntent(intent: AgentInterruptInputIntent): void
flushPending(): boolean | Promise<boolean>
dispose(): void
}
type AgentInterruptInferenceDeps = {
paneKey: string
getStatusEntry: () => AgentStatusEntry | undefined
inferInterrupt: (request: AgentInterruptInferenceRequest) => boolean | Promise<boolean> | void
now?: () => number
setTimer?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void
}
type CapturedInterruptBaseline = {
updatedAt: number
stateStartedAt: number
prompt: string
agentType: AgentStatusEntry['agentType']
intent: AgentInterruptInputIntent
inputCount?: number
}
function requiresDoubleEscapeForAgent(
agentType: AgentStatusEntry['agentType'],
intent: AgentInterruptInputIntent
): boolean {
return (agentType === 'opencode' || agentType === 'copilot') && intent === 'plain-escape'
}
function shouldFlushInterruptImmediately(
baseline: Pick<CapturedInterruptBaseline, 'agentType' | 'intent'>
): boolean {
return (
requiresDoubleEscapeForAgent(baseline.agentType, baseline.intent) ||
baseline.agentType === 'gemini'
)
}
function shouldIgnoreInterruptIntent(
agentType: AgentStatusEntry['agentType'],
intent: AgentInterruptInputIntent
): boolean {
return agentType === 'droid' && intent === 'ctrl-c'
}
function isSameTurnBaseline(
left: CapturedInterruptBaseline,
right: CapturedInterruptBaseline
): boolean {
return (
left.agentType === right.agentType &&
left.prompt === right.prompt &&
left.stateStartedAt === right.stateStartedAt
)
}
export function isPlainEscapeKeyEvent(
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey' | 'repeat'>
): boolean {
return (
event.key === 'Escape' &&
!event.repeat &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
)
}
export function isCtrlCKeyEvent(
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey' | 'repeat'>
): boolean {
return (
event.key.toLowerCase() === 'c' &&
!event.repeat &&
event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
)
}
export function createAgentInterruptInference({
paneKey,
getStatusEntry,
inferInterrupt,
now = () => Date.now(),
setTimer = (callback, ms) => setTimeout(callback, ms),
clearTimer = (timer) => clearTimeout(timer)
}: AgentInterruptInferenceDeps): AgentInterruptInference {
let pendingTimer: ReturnType<typeof setTimeout> | null = null
let pendingBaseline: CapturedInterruptBaseline | null = null
let doubleEscapeBaseline: CapturedInterruptBaseline | null = null
let doubleEscapeTimer: ReturnType<typeof setTimeout> | null = null
const clearPendingTimer = (): void => {
if (pendingTimer !== null) {
clearTimer(pendingTimer)
pendingTimer = null
}
pendingBaseline = null
}
const clearDoubleEscapeBaseline = (): void => {
doubleEscapeBaseline = null
if (doubleEscapeTimer !== null) {
clearTimer(doubleEscapeTimer)
doubleEscapeTimer = null
}
}
const clearPending = (): void => {
clearPendingTimer()
clearDoubleEscapeBaseline()
}
const captureBaseline = (
entry: AgentStatusEntry,
intent: AgentInterruptInputIntent
): CapturedInterruptBaseline | null => {
const agentType = entry.agentType
if (
entry.state !== 'working' ||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS)
) {
return null
}
return {
updatedAt: entry.updatedAt,
stateStartedAt: entry.stateStartedAt,
prompt: entry.prompt,
agentType,
intent
}
}
const flushPending = (): boolean | Promise<boolean> => {
const baseline = pendingBaseline
pendingTimer = null
pendingBaseline = null
if (!baseline) {
return false
}
const entry = getStatusEntry()
if (
entry &&
(entry.state !== 'working' ||
entry.agentType !== baseline.agentType ||
entry.prompt !== baseline.prompt ||
entry.updatedAt !== baseline.updatedAt ||
entry.stateStartedAt !== baseline.stateStartedAt ||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS))
) {
return false
}
if (!entry && now() - baseline.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
return false
}
const result = inferInterrupt({
paneKey,
baselineUpdatedAt: baseline.updatedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: baseline.prompt,
baselineAgentType: baseline.agentType,
intent: baseline.intent,
...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {})
})
return result ?? true
}
const flushPendingFromTimer = (): void => {
void flushPending()
}
return {
observeInputIntent(intent) {
const entry = getStatusEntry()
if (!entry) {
clearPending()
return
}
let baseline = captureBaseline(entry, intent)
if (!baseline) {
clearPending()
return
}
if (shouldIgnoreInterruptIntent(baseline.agentType, intent)) {
clearPending()
return
}
if (requiresDoubleEscapeForAgent(baseline.agentType, intent)) {
const isSecondEscape =
doubleEscapeBaseline !== null && isSameTurnBaseline(doubleEscapeBaseline, baseline)
doubleEscapeBaseline = baseline
clearPendingTimer()
if (!isSecondEscape) {
if (doubleEscapeTimer !== null) {
clearTimer(doubleEscapeTimer)
}
// Why: some TUIs use the first Escape as an editor/menu cancel. Do
// not let that arm a later single Escape indefinitely.
doubleEscapeTimer = setTimer(clearDoubleEscapeBaseline, AGENT_INTERRUPT_SETTLE_MS)
return
}
clearDoubleEscapeBaseline()
// Why: these agents use the first Escape as a TUI/editor cancel. The
// second Escape on the same turn is the actual running-turn interrupt.
baseline = { ...baseline, inputCount: 2 }
} else {
clearDoubleEscapeBaseline()
clearPendingTimer()
}
pendingBaseline = baseline
if (shouldFlushInterruptImmediately(baseline)) {
// Why: these agents can emit their idle/done hook immediately after an
// accepted interrupt. Flush before that hook overwrites the working baseline.
void flushPending()
return
}
pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS)
},
flushPending,
dispose() {
clearPending()
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,11 @@
/* oxlint-disable max-lines */
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { IDisposable } from '@xterm/xterm'
import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status'
import {
detectAgentStatusFromTitle,
isGeminiTerminalTitle,
isClaudeAgent
} from '@/lib/agent-status'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { useAppStore } from '@/store'
import type { PtyConnectResult } from './pty-transport'
@@ -30,6 +34,15 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { e2eConfig } from '@/lib/e2e-config'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import {
createAgentInterruptInference,
isCtrlCKeyEvent,
isPlainEscapeKeyEvent
} from './agent-interrupt-inference'
import {
AGENT_INTERRUPT_SETTLE_MS,
type AgentInterruptInputIntent
} from '../../../../shared/agent-interrupt-intent'
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
@@ -193,19 +206,234 @@ export function connectPanePty(
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
const cacheKey = makePaneKey(deps.tabId, pane.leafId)
const pendingSpawnKey = cacheKey
const neutralTerminalTitle = (): string => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
(entry) => entry.id === deps.tabId
)
return tab?.defaultTitle?.trim() || 'Terminal'
}
const clearInferredInterruptWorkingTitle = (): void => {
const state = useAppStore.getState()
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle
const title = currentTitle ?? statusTitle
if (!title) {
return
}
const neutralTitle = neutralTerminalTitle()
// Why: inferred interrupts update the explicit hook row, but many CLIs leave
// their OSC title stuck on a working spinner. Replace only this fallback
// title signal with a neutral terminal label so the existing process tracker
// can still decide whether an agent TUI is truly alive.
deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle)
if (manager.getActivePane()?.id === pane.id) {
deps.updateTabTitle(deps.tabId, neutralTitle)
}
}
let titleOnlyInterruptTimer: ReturnType<typeof setTimeout> | null = null
const clearTitleOnlyInterruptTimer = (): void => {
if (titleOnlyInterruptTimer !== null) {
clearTimeout(titleOnlyInterruptTimer)
titleOnlyInterruptTimer = null
}
}
const observeTitleOnlyInterrupt = (): void => {
const state = useAppStore.getState()
if (state.agentStatusByPaneKey[cacheKey]) {
return
}
const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
(entry) => entry.id === deps.tabId
)?.title
const baselineTitle = runtimeTitle ?? tabTitle
if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') {
return
}
clearTitleOnlyInterruptTimer()
titleOnlyInterruptTimer = setTimeout(() => {
titleOnlyInterruptTimer = null
if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) {
return
}
const currentState = useAppStore.getState()
const currentRuntimeTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
const currentTabTitle = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find(
(entry) => entry.id === deps.tabId
)?.title
const currentTitle = currentRuntimeTitle ?? currentTabTitle
if (
currentTitle === baselineTitle &&
detectAgentStatusFromTitle(currentTitle ?? '') === 'working'
) {
// Why: title-only agents such as Pi can miss their own idle title after
// Ctrl+C. Clear only an unchanged, acknowledged working title.
clearInferredInterruptWorkingTitle()
}
}, AGENT_INTERRUPT_SETTLE_MS)
}
const interruptInference = createAgentInterruptInference({
paneKey: cacheKey,
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
inferInterrupt: (request) => {
// Why: the explicit hook row is the authority for an in-flight agent turn.
// Codex can reset its terminal title while handling Ctrl+C/Escape, so title
// state must not veto clearing the row's working state.
return window.api.agentStatus
.inferInterrupt(request)
.then((applied) => {
if (applied) {
clearInferredInterruptWorkingTitle()
}
return applied
})
.catch((err) => {
console.warn('[agent-interrupt] inferInterrupt failed:', err)
return false
})
}
})
const dropCommandFinishedStatusIfSameTurn = (
entry: AgentStatusEntry | undefined,
options?: { allowInferredInterrupt?: boolean }
): void => {
if (!entry) {
return
}
const state = useAppStore.getState()
const current = state.agentStatusByPaneKey[cacheKey]
if (!current) {
return
}
const unchanged =
current.state === entry.state &&
current.prompt === entry.prompt &&
current.updatedAt === entry.updatedAt &&
current.stateStartedAt === entry.stateStartedAt &&
current.agentType === entry.agentType
const inferredFromEntry =
options?.allowInferredInterrupt === true &&
current.state === 'done' &&
current.interrupted === true &&
current.prompt === entry.prompt &&
current.agentType === entry.agentType &&
current.stateHistory?.some(
(history) =>
history.state === entry.state &&
history.prompt === entry.prompt &&
history.startedAt === entry.stateStartedAt
) === true
if (!unchanged && !inferredFromEntry) {
return
}
state.dropAgentStatus(cacheKey)
}
let pendingTerminalInputIntent: AgentInterruptInputIntent | null = null
let clearPendingTerminalInputIntentTimer: ReturnType<typeof setTimeout> | null = null
const clearPendingTerminalInputIntent = (): void => {
pendingTerminalInputIntent = null
if (clearPendingTerminalInputIntentTimer !== null) {
clearTimeout(clearPendingTerminalInputIntentTimer)
clearPendingTerminalInputIntentTimer = null
}
}
const setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => {
clearPendingTerminalInputIntent()
pendingTerminalInputIntent = intent
clearPendingTerminalInputIntentTimer = setTimeout(() => {
clearPendingTerminalInputIntent()
}, 0)
}
const inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => {
return (
(intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03')
)
}
const inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => {
if (data === '\x03') {
return 'ctrl-c'
}
if (data === '\x1b') {
return 'plain-escape'
}
return null
}
const observeSentTerminalInputIntent = (
data: string,
intent = pendingTerminalInputIntent
): void => {
if (intent && inputMatchesIntent(intent, data)) {
interruptInference.observeInputIntent(intent)
observeTitleOnlyInterrupt()
}
}
let pendingTerminalInputWrite: Promise<void> | null = null
const setPendingTerminalInputWrite = (promise: Promise<void>): void => {
pendingTerminalInputWrite = promise
void promise.finally(() => {
if (pendingTerminalInputWrite === promise) {
pendingTerminalInputWrite = null
}
})
}
const flushPendingInterruptInference = (): boolean | Promise<boolean> => {
const pendingWrite = pendingTerminalInputWrite
if (!pendingWrite) {
return interruptInference.flushPending()
}
return pendingWrite.then(() => interruptInference.flushPending())
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const inferenceResult = flushPendingInterruptInference()
if (inferenceResult === true) {
// Why: OSC 133 D means the foreground shell command exited. If an
// interrupt was inferred first, drop only when the current interrupted
// row is still the same turn; otherwise a killed OpenCode CLI leaves a
// stale "interrupted" row even though the process is gone.
dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
return
}
if (inferenceResult instanceof Promise) {
void inferenceResult.then((applied) => {
dropCommandFinishedStatusIfSameTurn(entry, {
allowInferredInterrupt: applied === true
})
})
return
}
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
// row without retaining a done snapshot; this section represents a live
// agent process, and the shell prompt means that process is gone.
if (entry) {
state.dropAgentStatus(cacheKey)
}
dropCommandFinishedStatusIfSameTurn(entry)
}
})
commandLifecycle.attachXtermConsumer(pane.terminal)
const onTerminalKeyDown = (event: KeyboardEvent): void => {
if (isPlainEscapeKeyEvent(event)) {
setPendingTerminalInputIntent('plain-escape')
return
}
if (isCtrlCKeyEvent(event)) {
if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) {
return
}
setPendingTerminalInputIntent('ctrl-c')
}
}
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
// distinguish plain Escape from Alt/meta sequences, and programmatic writes
// should not clear agent status.
const terminalKeyTarget = pane.terminal.element ?? pane.container
const terminalKeyTargetSupportsEvents =
typeof terminalKeyTarget?.addEventListener === 'function' &&
typeof terminalKeyTarget?.removeEventListener === 'function'
if (terminalKeyTargetSupportsEvents) {
terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true })
}
const agentCompletionCoordinator = createAgentCompletionCoordinator({
paneKey: cacheKey,
@@ -511,10 +739,9 @@ export function connectPanePty(
// the agent has exited. Clear any running cache timer so the sidebar doesn't
// show a stale countdown for a tab that no longer has an active Claude session.
deps.setCacheTimerStartedAt(cacheKey, null)
// Why: do not let terminal-title reversion own agent-status lifecycle.
// Explicit hooks and OSC 133 command-finished marks are the reliable
// signals; title changes can race normal "done" states and make agents
// look like they vanished as soon as they finished responding.
clearTitleOnlyInterruptTimer()
// Why: title reversion alone is not process death. The process/PTY tracker
// owns removing agent rows when the TUI actually exits.
}
// Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their
// callbacks to the correct Orca pane without resolving worktrees from cwd.
@@ -611,6 +838,7 @@ export function connectPanePty(
// the block still holds during reconnect races before the live transport has
// updated its local PTY binding.
if (isCodexPaneStale({ tabId: deps.tabId, panePtyId: currentPtyId })) {
clearPendingTerminalInputIntent()
return
}
// Why: presence-lock input drop. While mobile is the driver for this
@@ -621,6 +849,7 @@ export function connectPanePty(
// The pty:write IPC has a defense-in-depth twin. See
// docs/mobile-presence-lock.md.
if (currentPtyId && isPtyLocked(currentPtyId)) {
clearPendingTerminalInputIntent()
return
}
// Why: a real keystroke into the terminal is the unambiguous "user is
@@ -629,7 +858,38 @@ export function connectPanePty(
// auto-replies never count as interaction.
deps.clearTerminalTabUnread(deps.tabId)
deps.clearWorktreeUnread(deps.worktreeId)
transport.sendInput(data)
const intent = pendingTerminalInputIntent
// Why: real xterm can deliver the terminal byte even when our DOM keydown
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
// infer for local/remote acknowledged writes; SSH fire-and-forget remains
// excluded because those transports do not expose sendInputAccepted.
const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data)
if (acknowledgedIntent && transport.sendInputAccepted) {
clearPendingTerminalInputIntent()
const writePromise = transport
.sendInputAccepted(data)
.then((accepted) => {
if (accepted) {
interruptInference.observeInputIntent(acknowledgedIntent)
observeTitleOnlyInterrupt()
}
})
.catch((err) => {
console.warn('[agent-interrupt] acknowledged terminal input failed:', err)
})
setPendingTerminalInputWrite(writePromise)
return
}
if (intent) {
transport.sendInput(data)
clearPendingTerminalInputIntent()
return
}
if (transport.sendInput(data)) {
observeSentTerminalInputIntent(data)
} else {
clearPendingTerminalInputIntent()
}
})
const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => {
@@ -1484,6 +1744,13 @@ export function connectPanePty(
return {
dispose() {
disposed = true
if (terminalKeyTargetSupportsEvents) {
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
}
clearPendingTerminalInputIntent()
pendingTerminalInputWrite = null
interruptInference.dispose()
clearTitleOnlyInterruptTimer()
// Why: actively resolve any in-flight passphrase-gate waits so their
// zustand subscribers + async IIFEs don't hang for the rest of the
// session when the pane is torn down before SSH state changes.
@@ -264,6 +264,7 @@ export type PtyTransport = {
}) => void
disconnect: () => void
sendInput: (data: string) => boolean
sendInputAccepted?: (data: string) => Promise<boolean>
resize: (
cols: number,
rows: number,
@@ -28,6 +28,7 @@ describe('createIpcPtyTransport', () => {
...originalWindow?.api?.pty,
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
write: vi.fn(),
writeAccepted: vi.fn().mockResolvedValue(true),
resize: vi.fn(),
kill: vi.fn(),
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
@@ -67,6 +68,19 @@ describe('createIpcPtyTransport', () => {
transport.disconnect()
})
it('uses acknowledged writes only for local IPC PTYs', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const localTransport = createIpcPtyTransport({})
await localTransport.connect({ url: '', callbacks: {} })
await expect(localTransport.sendInputAccepted?.('\x03')).resolves.toBe(true)
expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03')
const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' })
await sshTransport.connect({ url: '', callbacks: {} })
expect(sshTransport.sendInputAccepted).toBeUndefined()
})
it('suppresses attention side effects when replaying eager-buffered data during attach', async () => {
// Why: eager PTY buffers capture output produced before the pane mounted —
// typically catch-up bytes from a previous app session. A BEL or
@@ -520,6 +520,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return true
},
...(connectionId
? {}
: {
async sendInputAccepted(data: string): Promise<boolean> {
if (!connected || !ptyId) {
return false
}
return window.api.pty.writeAccepted(ptyId, data)
}
}),
resize(cols: number, rows: number): boolean {
if (!connected || !ptyId) {
return false
@@ -1,5 +1,6 @@
export type RemoteRuntimePtyBatcher = {
push: (data: string) => void
takePending: () => string
flush: () => void
clear: () => void
}
@@ -25,14 +26,19 @@ export function createRemoteRuntimePtyTextBatcher(
}
const flush = (): void => {
const text = pending
pending = ''
clear()
const text = takePending()
if (text) {
onFlush(text)
}
}
const takePending = (): string => {
const text = pending
pending = ''
clear()
return text
}
return {
push(data: string): void {
pending += data
@@ -40,6 +46,7 @@ export function createRemoteRuntimePtyTextBatcher(
timer = setTimeout(flush, delayMs)
}
},
takePending,
flush,
clear
}
@@ -661,6 +661,111 @@ describe('createRemoteRuntimePtyTransport', () => {
}
})
it('returns runtime acceptance for acknowledged terminal input', async () => {
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
}
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: true,
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true)
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.send',
params: {
terminal: 'terminal-1',
text: '\x03',
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
},
timeoutMs: 15_000
})
})
it('preserves queued remote input order before acknowledged terminal input', async () => {
vi.useFakeTimers()
try {
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
}
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: true,
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 2 } }
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
subscriptionSendBinary.mockClear()
expect(transport.sendInput('a')).toBe(true)
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true)
await vi.runOnlyPendingTimersAsync()
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.send',
params: {
terminal: 'terminal-1',
text: 'a\x03',
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
},
timeoutMs: 15_000
})
expect(subscriptionSendBinary).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('returns false when acknowledged terminal input is rejected by the runtime', async () => {
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
}
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: true,
result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } }
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false)
})
it('preserves literal LF input when sending remote PTY binary frames', async () => {
vi.useFakeTimers()
try {
@@ -2,7 +2,8 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalCreate
RuntimeTerminalCreate,
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher'
import { createPtyOutputProcessor } from './pty-transport'
@@ -206,6 +207,28 @@ export function createRemoteRuntimePtyTransport(
}
}
async function sendInputAcceptedToRuntime(data: string): Promise<boolean> {
const targetHandle = handle
if (!connected || !targetHandle) {
return false
}
if (!data) {
return true
}
const text = `${inputBatcher.takePending()}${data}`
try {
const result = await callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: targetHandle,
text,
client: { id: clientId, type: 'desktop' }
})
return result.send.accepted === true
} catch (error) {
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
return false
}
}
const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => {
const targetHandle = handle
if (!connected || !targetHandle) {
@@ -458,6 +481,8 @@ export function createRemoteRuntimePtyTransport(
return true
},
sendInputAccepted: sendInputAcceptedToRuntime,
resize(cols: number, rows: number): boolean {
if (!connected || !handle) {
return false
+2
View File
@@ -191,6 +191,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
agentStatus: {
onSet: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
@@ -1080,6 +1081,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
return {
spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')),
write: () => {},
writeAccepted: () => Promise.resolve(false),
resize: () => {},
reportGeometry: () => {},
signal: () => {},
+28 -8
View File
@@ -133,6 +133,11 @@ export type AgentHookEventPayload = {
* HTTP path always sets null because it cannot know which mux a request
* came from. See docs/design/agent-status-over-ssh.md §5. */
connectionId: string | null
/** True when this hook event carried prompt text directly, instead of using
* the listener's cached prompt from an earlier event in the same pane. */
hasExplicitPrompt?: boolean
/** True when this event is a relay cache replay rather than a live hook. */
isReplay?: boolean
payload: ParsedAgentStatusPayload
}
@@ -1523,19 +1528,25 @@ function normalizeCursorEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
// Why: Cursor can emit the final response text after `stop`; that should
// enrich the completed row, not resurrect the agent as working.
const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload
const stateName =
eventName === 'beforeSubmitPrompt' ||
eventName === 'sessionStart' ||
eventName === 'preToolUse' ||
eventName === 'postToolUse' ||
eventName === 'postToolUseFailure' ||
eventName === 'afterAgentResponse'
eventName === 'postToolUseFailure'
? 'working'
: eventName === 'stop' || eventName === 'sessionEnd'
? 'done'
: eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution'
? 'waiting'
: null
: eventName === 'afterAgentResponse'
? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor'
? 'done'
: 'working'
: eventName === 'stop' || eventName === 'sessionEnd'
? 'done'
: eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution'
? 'waiting'
: null
if (!stateName) {
return null
@@ -1973,7 +1984,16 @@ export function normalizeHookPayload(
// it null; the relay forwards null on the wire and Orca's `ingestRemote`
// stamps the real value from `mux` identity on receive. See
// docs/design/agent-status-over-ssh.md §5.
return payload ? { paneKey, tabId, worktreeId, connectionId: null, payload } : null
return payload
? {
paneKey,
tabId,
worktreeId,
connectionId: null,
hasExplicitPrompt: promptText.length > 0,
payload
}
: null
}
// ─── URL routing ────────────────────────────────────────────────────
+5
View File
@@ -54,6 +54,11 @@ export type AgentHookRelayEnvelope = {
worktreeId?: string
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
connectionId: null
/** Preserved from the relay-side normalized hook event so Orca can
* distinguish a true same-prompt retry from a cached-prompt tool ping. */
hasExplicitPrompt?: boolean
/** True when the relay is replaying its cache after Orca reconnects. */
isReplay?: boolean
/** Forwarded from the agent CLI POST body. The relay default is `remote`,
* which marks transport location rather than dev/prod build env. */
env?: string
+19
View File
@@ -0,0 +1,19 @@
import type { AgentType } from './agent-status-types'
export type AgentInterruptInputIntent = 'plain-escape' | 'ctrl-c'
export const AGENT_INTERRUPT_SETTLE_MS = 500
export type AgentInterruptInferenceRequest = {
paneKey: string
baselineUpdatedAt: number
baselineStateStartedAt: number
baselinePrompt: string
baselineAgentType: AgentType | undefined
intent: AgentInterruptInputIntent
inputCount?: number
}
export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent {
return intent === 'plain-escape' || intent === 'ctrl-c'
}
+12 -9
View File
@@ -1,7 +1,9 @@
// ─── Explicit agent status (reported via native agent hooks → IPC) ──────────
// These types define the normalized status that Orca receives from Claude,
// Codex, and other explicit integrations. Agent state is hook-reported only —
// we do not infer status from terminal titles anywhere in the data flow.
// Codex, and other explicit integrations. Agent state normally comes from
// hooks; a narrow interrupt fallback may synthesize a final done state when an
// agent misses its own cancellation hook. We still do not infer status from
// terminal titles anywhere in the data flow.
export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const
export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number]
@@ -39,10 +41,10 @@ export type AgentStateHistoryEntry = {
prompt: string
/** When this state was first reported. */
startedAt: number
/** True when this `done` was a cancellation (user hit ESC/Ctrl+C). Reported
* by the agent itself — Claude Code sets `is_interrupt: true` on its `Stop`
* hook when the turn ended via interrupt. Always falsy for non-`done`
* states, so retention logic can preserve this signal. */
/** True when this `done` was a cancellation. May come from an agent hook
* (for example Claude Code `is_interrupt`) or Orca's guarded interrupt
* fallback. Always falsy for non-`done` states, so retention logic can
* preserve this signal. */
interrupted?: boolean
}
@@ -77,10 +79,11 @@ export type AgentStatusEntry = {
/** Most recent assistant message preview, when the hook carried one. */
lastAssistantMessage?: string
/** True when the current `done` state was reached via an interrupt rather
* than a normal turn completion (Claude Code's `is_interrupt: true`).
* than a normal turn completion. May be reported by the agent itself or
* inferred by Orca's guarded interrupt fallback.
* Orthogonal to `state`: the agent still finished the turn, but the user
* cancelled it. Undefined while the agent is working or for non-Claude
* agents that don't surface this signal. */
* cancelled it. Undefined while the agent is working or when no interrupt
* signal was available. */
interrupted?: boolean
}
+173 -21
View File
@@ -1,5 +1,7 @@
/* eslint-disable max-lines -- Localhost SSH E2E covers setup, remote PTY, hook relay, and interrupt inference in one expensive app boot. */
import os from 'os'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
@@ -72,6 +74,65 @@ function emitMarkerCommand(value: string): string {
)}`
}
async function focusTerminal(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active worktree')
}
const tabId =
state.activeTabType === 'terminal'
? state.activeTabId
: (state.activeTabIdByWorktree?.[worktreeId] ?? null)
if (!tabId) {
throw new Error('No active terminal tab')
}
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
if (!pane) {
throw new Error('No active terminal pane')
}
pane.terminal.focus()
})
}
async function postCodexHook(
page: Page,
ptyId: string,
payload: Record<string, unknown>,
markerName: string
): Promise<void> {
const hookPostedMarker = marker(markerName)
await execInTerminal(
page,
ptyId,
[
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' echo __ORCA_AGENT_HOOK_ENV_MISSING__',
'else',
` hook_payload=${shellQuote(JSON.stringify(payload))}`,
' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${hook_payload}" >/dev/null; then',
` ${emitMarkerCommand(hookPostedMarker)}`,
' fi',
'fi'
].join('\n')
)
await waitForTerminalOutput(page, hookPostedMarker, 20_000)
}
test.describe('Localhost SSH', () => {
test.skip(
!RUN_LOCALHOST_SSH,
@@ -201,6 +262,17 @@ test.describe('Localhost SSH', () => {
})
const paneKeyLeafId = paneKey.slice(paneKey.indexOf(':') + 1)
expect(paneKeyLeafId).toMatch(UUID_RE)
await orcaPage.evaluate(() => {
const state = window as unknown as {
__sshAgentStatusEvents?: unknown[]
__sshAgentStatusUnsubscribe?: () => void
}
state.__sshAgentStatusEvents = []
state.__sshAgentStatusUnsubscribe?.()
state.__sshAgentStatusUnsubscribe = window.api.agentStatus.onSet((event) => {
state.__sshAgentStatusEvents?.push(event)
})
})
const terminalMarker = marker('LOCALHOST_SSH')
await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker))
@@ -240,30 +312,12 @@ test.describe('Localhost SSH', () => {
await waitForTerminalOutput(orcaPage, pluginOverlayMarker, 20_000)
const prompt = `orca ssh e2e prompt ${Date.now()}`
const hookPostedMarker = marker('AGENT_HOOK_POSTED')
await execInTerminal(
await postCodexHook(
orcaPage,
ptyId,
[
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' echo __ORCA_AGENT_HOOK_ENV_MISSING__',
'else',
` hook_payload=${shellQuote(JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }))}`,
' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${hook_payload}" >/dev/null; then',
` ${emitMarkerCommand(hookPostedMarker)}`,
' fi',
'fi'
].join('\n')
{ hook_event_name: 'UserPromptSubmit', prompt },
'AGENT_HOOK_POSTED'
)
await waitForTerminalOutput(orcaPage, hookPostedMarker, 20_000)
await expect
.poll(
@@ -292,5 +346,103 @@ test.describe('Localhost SSH', () => {
}
)
.toBe(true)
const ctrlPrompt = `orca ssh ctrl-c interrupt ${Date.now()}`
await postCodexHook(
orcaPage,
ptyId,
{ hook_event_name: 'UserPromptSubmit', prompt: ctrlPrompt },
'AGENT_HOOK_CTRL_WORKING'
)
await focusTerminal(orcaPage)
await orcaPage.keyboard.press('Control+C')
await orcaPage.waitForTimeout(750)
expect(
await orcaPage.evaluate(
({ paneKey, prompt, targetId, worktreeId }) => {
const state = window.__store?.getState()
const entry = state?.agentStatusByPaneKey[paneKey]
const events =
(
window as unknown as {
__sshAgentStatusEvents?: {
prompt?: string
connectionId?: string | null
worktreeId?: string
}[]
}
).__sshAgentStatusEvents ?? []
return {
state: entry?.state,
interrupted: entry?.interrupted,
prompt: entry?.prompt,
eventMatched: events.some(
(event) =>
event.prompt === prompt &&
event.connectionId === targetId &&
event.worktreeId === worktreeId
)
}
},
{ paneKey, prompt: ctrlPrompt, targetId: remote.targetId, worktreeId: remote.worktreeId }
)
).toEqual({
state: 'working',
interrupted: undefined,
prompt: ctrlPrompt,
eventMatched: true
})
await postCodexHook(
orcaPage,
ptyId,
{
hook_event_name: 'PreToolUse',
tool_name: 'exec_command',
tool_input: { cmd: '/bin/sleep 90' }
},
'AGENT_HOOK_LATE_WORKING'
)
await expect
.poll(
() =>
orcaPage.evaluate(
({ paneKey }) => {
const entry = window.__store?.getState().agentStatusByPaneKey[paneKey]
return {
state: entry?.state,
interrupted: entry?.interrupted,
prompt: entry?.prompt
}
},
{ paneKey }
),
{ timeout: 5_000, message: 'Late remote working hook did not remain working' }
)
.toEqual({ state: 'working', interrupted: undefined, prompt: ctrlPrompt })
const escapePrompt = `orca ssh escape interrupt ${Date.now()}`
await postCodexHook(
orcaPage,
ptyId,
{ hook_event_name: 'UserPromptSubmit', prompt: escapePrompt },
'AGENT_HOOK_ESCAPE_WORKING'
)
await focusTerminal(orcaPage)
await orcaPage.keyboard.press('Escape')
await orcaPage.waitForTimeout(750)
expect(
await orcaPage.evaluate(
({ paneKey }) => {
const entry = window.__store?.getState().agentStatusByPaneKey[paneKey]
return {
state: entry?.state,
interrupted: entry?.interrupted,
prompt: entry?.prompt
}
},
{ paneKey }
)
).toEqual({ state: 'working', interrupted: undefined, prompt: escapePrompt })
})
})