Keep Hermes and Antigravity helper IDs out of terminal recovery (#36349)

* fix(copilot): keep helper IDs out of pane recovery

* test(copilot): use recorded root hook shape and fixed clocks

* test(copilot): fail on rejected hooks before ingesting them

* fix(hermes): bind terminal recovery only from root hooks

* fix(antigravity): derive terminal recovery from conversation origin

* Preserve hook event typing and verify bounded transcript reads

* fix: retain accepted hook session binding through reentry

* test: use controlled time for hook binding fixtures

* fix: preserve hook binding type and test boundaries
This commit is contained in:
Brennan Benson
2026-10-30 11:36:49 -07:00
committed by GitHub
parent cc69f3dda2
commit b83e76f01e
23 changed files with 1466 additions and 103 deletions
@@ -0,0 +1,215 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { resumeRecordFromStatus } from '../../shared/agent-resume-record-from-status'
import { getAgentResumeArgv } from '../../shared/agent-session-resume'
import { TRANSCRIPT_MAX_SCAN_BYTES } from '../../shared/agent-hook-listener/transcript-reader'
import { AgentHookServer } from './server'
import { buildBody, PANE } from './server.test-fixtures'
// Exact origin metadata and hook shape captured from agy 1.4.11; paths/IDs are test-local.
const ROOT = '6bba92d4-47e1-440a-9571-b12bb2d474bc'
const CHILD = '2e290489-f9b1-432b-807d-f5f2acdf9421'
const ROOT_ORIGIN = {
step_index: 0,
source: 'USER_EXPLICIT',
type: 'USER_INPUT',
content: 'Reply with OK.'
}
const CHILD_ORIGIN = {
step_index: 0,
source: 'SYSTEM',
type: 'SYSTEM_MESSAGE',
content: 'sender=0ec29085-37fc-4105-b0a9-9afb536af06c'
}
const CHILD_HOOKS = [
{ hook_event_name: 'PreInvocation', initialNumSteps: 0, invocationNum: 0 },
{ hook_event_name: 'PreToolUse', toolCall: { name: 'read_file', args: {} } },
{ hook_event_name: 'PostInvocation', invocationNum: 0 },
{ hook_event_name: 'Stop', fullyIdle: true, terminationReason: 'NO_TOOL_CALL', error: '' }
]
function jsonl(...records: unknown[]): string {
return `${records.map((record) => JSON.stringify(record)).join('\n')}\n`
}
function normalized(payload: Record<string, unknown>, state = createHookListenerState()) {
const event = normalizeHookPayload(state, 'antigravity', buildBody(payload), 'production')
if (!event) {
throw new Error('Missing Antigravity event')
}
return event
}
function hook(transcriptPath: string, conversationId = ROOT, extra: Record<string, unknown> = {}) {
return {
hook_event_name: 'PreInvocation',
conversationId,
transcriptPath,
lastUserInput: 'Reply with OK.',
modelName: 'gemini-3.8-flash-high',
...extra
}
}
function expectResume(server: AgentHookServer, id = ROOT) {
const status = server.getStatusSnapshot()[0]
if (!status) {
throw new Error('Missing status')
}
expect(status).not.toHaveProperty('statusAdmissionSession')
const record = resumeRecordFromStatus({
entry: { ...status, updatedAt: status.receivedAt },
worktreeId: 'wt-1',
capturedAt: status.receivedAt
})
expect(record?.providerSession.id).toBe(id)
if (!record) {
throw new Error('Missing resume record')
}
expect(getAgentResumeArgv('antigravity', record.providerSession)).toEqual([
'agy',
'--conversation',
id
])
}
it.each(CHILD_HOOKS.map((_, index) => index))(
'keeps persisted root across cold child suffix %s',
async (start) => {
const dir = mkdtempSync(join(tmpdir(), 'agy-resume-'))
const rootPath = join(dir, 'root.jsonl')
const childPath = join(dir, 'child.jsonl')
writeFileSync(rootPath, jsonl(ROOT_ORIGIN))
writeFileSync(childPath, jsonl(CHILD_ORIGIN, ROOT_ORIGIN))
let server = new AgentHookServer()
try {
await server.start({ env: 'production', userDataPath: dir })
server.ingestRemote(normalized(hook(rootPath)), 'host-1')
server.stop()
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
for (const suffix of CHILD_HOOKS.slice(start)) {
const event = normalized(hook(childPath, CHILD, { lastUserInput: '', ...suffix }))
expect(event.providerSession).toBeUndefined()
server.ingestRemote(event, 'host-1')
expectResume(server)
}
server.stop()
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
expectResume(server)
server.ingestRemote(normalized(hook(rootPath, 'later-root')), 'host-1')
expectResume(server, 'later-root')
server.ingestRemote(
{ ...normalized(hook(childPath, CHILD)), worktreeId: 'other-worktree' },
'host-1'
)
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
server.ingestRemote(normalized(hook(childPath, CHILD)), 'host-2')
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
server.ingestRemote(normalized(hook(childPath, CHILD)), 'host-1')
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
it('derives root identity from bounded first-record provenance, independently of latest input', () => {
const dir = mkdtempSync(join(tmpdir(), 'agy-origin-'))
const transcriptPath = join(dir, 'transcript.jsonl')
const server = new AgentHookServer()
try {
const accepts = (first: unknown, lastUserInput?: unknown) => {
writeFileSync(transcriptPath, jsonl(first, ROOT_ORIGIN))
return normalized(hook(transcriptPath, ROOT, { lastUserInput }))
}
for (const first of [CHILD_ORIGIN, { source: 'USER', type: 'REQUEST' }, {}, null]) {
const event = accepts(first, 'nonempty input grants no authority')
expect(event.providerSession).toBeUndefined()
server.ingestRemote(event, null)
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
}
for (const lastUserInput of ['Reply with OK.', '', undefined, null]) {
expect(accepts(ROOT_ORIGIN, lastUserInput).providerSession?.id).toBe(ROOT)
}
const state = createHookListenerState()
for (const text of [
'',
'{"source":"USER_EXPLICIT"',
`malformed\n${JSON.stringify(ROOT_ORIGIN)}`,
jsonl({ ...ROOT_ORIGIN, content: 'x'.repeat(TRANSCRIPT_MAX_SCAN_BYTES) })
]) {
writeFileSync(transcriptPath, text)
expect(normalized(hook(transcriptPath), state).providerSession).toBeUndefined()
}
rmSync(transcriptPath)
expect(normalized(hook(transcriptPath), state).providerSession).toBeUndefined()
writeFileSync(transcriptPath, jsonl(ROOT_ORIGIN))
expect(normalized(hook(dir), state).providerSession).toBeUndefined()
const root = normalized(hook(transcriptPath), state)
server.ingestRemote(root, null)
expectResume(server)
rmSync(transcriptPath)
server.ingestRemote(
normalized(hook(transcriptPath, ROOT, { hook_event_name: 'Stop', fullyIdle: true }), state),
null
)
expectResume(server)
server.ingestTerminalStatus({
paneKey: PANE,
worktreeId: 'wt-1',
origin: 'process',
payload: { state: 'working', prompt: '', agentType: 'antigravity' }
})
server.ingestRemote(normalized(hook(transcriptPath, CHILD)), null)
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
})
it('keeps warm-child dropping and helper-held completion status unchanged', () => {
const dir = mkdtempSync(join(tmpdir(), 'agy-status-'))
const rootPath = join(dir, 'root.jsonl')
const childPath = join(dir, 'child.jsonl')
writeFileSync(rootPath, jsonl(ROOT_ORIGIN))
writeFileSync(childPath, jsonl(CHILD_ORIGIN))
const state = createHookListenerState()
try {
normalized(
hook(rootPath, ROOT, {
hook_event_name: 'PreToolUse',
toolCall: { name: 'invoke_subagent', args: {} }
}),
state
)
expect(
normalizeHookPayload(
state,
'antigravity',
buildBody(hook(childPath, CHILD, { lastUserInput: '' })),
'production'
)
).toBeNull()
const held = normalized(
hook(rootPath, ROOT, { hook_event_name: 'Stop', fullyIdle: false }),
state
)
expect(held.payload).toMatchObject({ state: 'working', mainAgent: { state: 'done' } })
expect(held.providerSession?.id).toBe(ROOT)
const cold = normalized(
hook(childPath, CHILD, { lastUserInput: '', hook_event_name: 'Stop', fullyIdle: true })
)
expect(cold.payload.state).toBe('done')
expect(cold.providerSession).toBeUndefined()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
@@ -0,0 +1,98 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay'
import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server'
import { AgentHookServer } from './server'
import { buildBody, postHookEvent } from './server.test-fixtures'
it.each(['local', 'relay'] as const)(
'retains Antigravity status admission without promoting a raw locator over %s',
async (transport) => {
const dir = mkdtempSync(join(tmpdir(), 'agy-admission-'))
const transcriptPath = join(dir, 'transcript.jsonl')
const root = '6bba92d4-47e1-440a-9571-b12bb2d474bc'
writeFileSync(
transcriptPath,
`${JSON.stringify({ source: 'USER_EXPLICIT', type: 'USER_INPUT', content: 'Reply with OK.' })}\n`
)
const server = new AgentHookServer()
const forwarded: AgentHookRelayEnvelope[] = []
const relay = new RelayAgentHookServer({
endpointDir: dir,
forward: (event) => {
forwarded.push(event)
server.ingestRemote(event, 'host-1')
}
})
try {
await server.start({ env: 'production' })
if (transport === 'relay') {
await relay.start()
}
const post = async (source: 'antigravity' | 'codex', payload: Record<string, unknown>) => {
const body = buildBody(payload)
const response =
transport === 'local'
? await postHookEvent(server, body, `/hook/${source}`)
: await fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/${source}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': relay.getCoordinates().token
},
body: JSON.stringify(body)
})
expect(response.status).toBe(204)
}
const agy = (conversationId: string, extra: Record<string, unknown> = {}) => ({
hook_event_name: 'PreInvocation',
conversationId,
transcriptPath,
lastUserInput: 'Reply with OK.',
...extra
})
await post('antigravity', agy(root))
await post('antigravity', agy(root, { hook_event_name: 'Stop', fullyIdle: true }))
rmSync(transcriptPath)
await post('antigravity', agy('unknown-child', { lastUserInput: '' }))
const delayedUnknown = forwarded.at(-1)
await post('antigravity', agy(root, { hook_event_name: 'Stop', fullyIdle: true }))
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'nested',
prompt: 'review'
})
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
if (transport === 'relay') {
if (!delayedUnknown) {
throw new Error('Missing child relay event')
}
expect(delayedUnknown.agentPresence).toEqual({
agent: 'antigravity',
session: 'unknown-child'
})
server.ingestRemote(delayedUnknown, 'host-1')
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
}
await post('antigravity', agy('unknown-child', { lastUserInput: '' }))
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
await post('antigravity', agy(root))
expect(server.getStatusSnapshot()[0]).toMatchObject({
agentType: 'antigravity',
state: 'working'
})
expect(server.getStatusSnapshot()[0]?.providerSession).toEqual({
key: 'conversation_id',
id: root
})
expect(server.getStatusSnapshot()[0]).not.toHaveProperty('statusAdmissionSession')
expect(JSON.stringify(forwarded)).not.toContain('statusAdmissionSession')
} finally {
server.stop()
relay.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
@@ -0,0 +1,216 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { resumeRecordFromStatus } from '../../shared/agent-resume-record-from-status'
import { getAgentResumeArgv } from '../../shared/agent-session-resume'
import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay'
import { RelayAgentHookServer } from '../../wsl-guest/agent-hook-server'
import { AgentHookServer } from './server'
import { buildBody, PANE, postHookEvent } from './server.test-fixtures'
const ROOT = 'local-parent'
const CHILD = '20261024_child'
// Order captured from a real Hermes delegate_task run; tools/end omit platform.
const HOOKS = [
{ hook_event_name: 'on_session_start', session_id: CHILD, platform: 'subagent' },
{ hook_event_name: 'pre_llm_call', session_id: CHILD, platform: 'subagent' },
{ hook_event_name: 'pre_tool_call', session_id: CHILD, tool_name: 'read_file' },
{ hook_event_name: 'post_llm_call', session_id: CHILD, platform: 'subagent' },
{ hook_event_name: 'on_session_end', session_id: CHILD }
]
function normalize(payload: Record<string, unknown>) {
const event = normalizeHookPayload(
createHookListenerState(),
'hermes',
buildBody(payload),
'production'
)
if (!event) {
throw new Error('Hermes hook was rejected')
}
return event
}
function root(id = ROOT, platform = 'cli', hook_event_name = 'pre_llm_call') {
return normalize({ hook_event_name, session_id: id, platform, user_message: 'delegate it' })
}
function expectResume(server: AgentHookServer, id = ROOT) {
const status = server.getStatusSnapshot()[0]
if (!status) {
throw new Error('Missing Hermes status')
}
expect(status).not.toHaveProperty('statusAdmissionSession')
const record = resumeRecordFromStatus({
entry: { ...status, updatedAt: status.receivedAt },
worktreeId: 'wt-1',
capturedAt: status.receivedAt
})
expect(record?.providerSession.id).toBe(id)
if (!record) {
throw new Error('Missing Hermes resume record')
}
expect(getAgentResumeArgv('hermes', record.providerSession)).toEqual(['hermes', '--resume', id])
}
it.each(HOOKS.map((_, index) => index))(
'keeps the persisted root after restart before child hook %s',
async (start) => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-resume-identity-'))
let server = new AgentHookServer()
try {
await server.start({ env: 'production', userDataPath: dir })
server.ingestRemote(root(), 'host-1')
server.stop()
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
for (const hook of HOOKS.slice(start)) {
const event = normalize(hook)
expect(event.providerSession).toBeUndefined()
server.ingestRemote(event, 'host-1')
expectResume(server)
}
server.stop()
server = new AgentHookServer()
await server.start({ env: 'production', userDataPath: dir })
expectResume(server)
server.ingestRemote(root('selected-root', 'tui', 'on_session_reset'), 'host-1')
expectResume(server, 'selected-root')
server.ingestRemote(normalize(HOOKS[2]), 'host-2')
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
} finally {
server.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
it('requires root authority, never creates an unmarked tool locator, and respects host reset', () => {
const server = new AgentHookServer()
server.ingestRemote(normalize(HOOKS[2]), null)
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
for (const platform of ['cli', 'tui', 'desktop', 'acp']) {
server.ingestRemote(root(platform, platform), null)
expectResume(server, platform)
}
for (const platform of ['', 'subagent', 'telegram', undefined]) {
const event = normalize({ hook_event_name: 'pre_llm_call', session_id: CHILD, platform })
expect(event.providerSession).toBeUndefined()
server.ingestRemote(event, null)
expectResume(server, 'acp')
}
// An older normalized relay still exposes the non-authoritative event kind.
server.ingestRemote({ ...root(CHILD), hookEventName: 'pre_tool_call' }, null)
expectResume(server, 'acp')
server.ingestRemote(normalize({ hook_event_name: 'on_session_end', session_id: 'acp' }), null)
server.ingestTerminalStatus({
paneKey: PANE,
worktreeId: 'wt-1',
origin: 'process',
payload: { state: 'working', prompt: '', agentType: 'hermes' }
})
server.ingestRemote(normalize(HOOKS[2]), null)
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
server.stop()
})
it.each(['local', 'relay'] as const)(
'preserves root status re-entry without tool-derived restore identity over %s',
async (transport) => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-status-admission-'))
const server = new AgentHookServer()
const forwarded: AgentHookRelayEnvelope[] = []
const relay = new RelayAgentHookServer({
endpointDir: dir,
forward: (event) => {
forwarded.push(event)
server.ingestRemote(event, 'host-1')
}
})
try {
await server.start({ env: 'production' })
if (transport === 'relay') {
await relay.start()
}
const post = async (source: 'hermes' | 'codex', payload: Record<string, unknown>) => {
const body = buildBody(payload)
const response =
transport === 'local'
? await postHookEvent(server, body, `/hook/${source}`)
: await fetch(`http://127.0.0.1:${relay.getCoordinates().port}/hook/${source}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': relay.getCoordinates().token
},
body: JSON.stringify(body)
})
expect(response.status).toBe(204)
}
await post('hermes', { hook_event_name: 'pre_llm_call', session_id: ROOT, platform: 'cli' })
await post('hermes', {
hook_event_name: 'pre_llm_call',
session_id: CHILD,
platform: 'subagent'
})
await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD })
await post('hermes', {
hook_event_name: 'post_tool_call',
session_id: 'unknown-child',
tool_name: 'read_file'
})
const delayedUnknown = forwarded.at(-1)
await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD })
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'nested',
prompt: 'review'
})
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
if (transport === 'relay') {
if (!delayedUnknown) {
throw new Error('Missing relay child event')
}
expect(delayedUnknown.agentPresence).toEqual({ agent: 'hermes', session: 'unknown-child' })
server.ingestRemote(delayedUnknown, 'host-1')
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
}
await post('hermes', {
hook_event_name: 'post_tool_call',
session_id: 'unknown-child',
tool_name: 'read_file'
})
expect(server.getStatusSnapshot()[0]?.agentType).toBe('codex')
await post('hermes', {
hook_event_name: 'post_tool_call',
session_id: ROOT,
tool_name: 'delegate_task'
})
expect(server.getStatusSnapshot()[0]).toMatchObject({ agentType: 'hermes', state: 'working' })
expectResume(server)
// Existing warm-child status admission uses the same remembered parent, without binding its ID.
await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD })
expect(server.getStatusSnapshot()[0]?.state).toBe('done')
await post('hermes', { hook_event_name: 'pre_llm_call', session_id: ROOT, platform: 'cli' })
expectResume(server)
await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD })
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'nested',
prompt: 'review'
})
// Positive authority must replace a formerly substituted child before either admission gate.
await post('hermes', { hook_event_name: 'pre_llm_call', session_id: CHILD, platform: 'cli' })
expectResume(server, CHILD)
expect(JSON.stringify(forwarded)).not.toContain('statusAdmissionSession')
} finally {
server.stop()
relay.stop()
rmSync(dir, { recursive: true, force: true })
}
}
)
@@ -0,0 +1,431 @@
import type { BrowserWindow } from 'electron'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import type { HookSessionBindingRendererFixture } from '../../shared/agent-hook-session-binding.test-fixture'
import { normalizeHookPayload } from '../../shared/agent-hook-listener'
import {
createHookListenerState,
admitLegacyAgentStatus
} from '../../shared/agent-hook-listener/listener-state'
import { readAgentProcessPresence } from '../../shared/agent-process-presence'
import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../shared/agent-status-legacy-adapter'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { buildRelayHookEnvelope } from '../../wsl-guest/agent-hook-envelope-build'
import { RelayProviderHookNormalization } from '../../wsl-guest/agent-hook-provider-normalization'
import { hookRowPaneResumeRecord } from '../runtime/session-lifecycle/projection/hook-row-pane-resume-record'
import {
hookStatusResumeCommand,
quitResumeRecords,
resumeStatusFromHookRow
} from '../runtime/terminal/stop/hook-status-resume-records'
import {
installMainWindowAgentStatusListeners,
clearMainWindowAgentStatusListeners
} from '../startup/main-window-agent-status'
import { agentHookServer as server } from './server'
import { buildBody, PANE, GOOD_PANE, postHookEvent } from './server.test-fixtures'
const bridge = vi.hoisted(() => ({
send: vi.fn<(channel: string, data: AgentStatusIpcPayload) => void>()
}))
vi.mock('../startup/main-process-state', () => ({
mainProcessState: { mainWindow: { isDestroyed: () => false, webContents: bridge }, runtime: null }
}))
vi.mock('../window/dashboard-popout-window', () => ({ getDashboardPopoutWindow: () => null }))
vi.mock('../startup/synthetic-title-runtime', () => ({ stopSyntheticTitleSpinnerTimer: () => {} }))
const rendererFixture: unknown = await vi.importActual(
'../../renderer/src/store/slices/agent-status/hook-session-binding-renderer.test-fixture'
)
function isRendererFixture(value: unknown): value is HookSessionBindingRendererFixture {
return (
typeof value === 'object' &&
value !== null &&
'expectReturnedRenderer' in value &&
typeof value.expectReturnedRenderer === 'function'
)
}
if (!isRendererFixture(rendererFixture)) {
throw new Error('Missing renderer hook binding assertion fixture')
}
const { expectReturnedRenderer } = rendererFixture
const ROOT = 'ordinary-root-B'
const CHILD = 'delegated-child-A'
let dir: string
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'hook-session-binding-'))
bridge.send.mockClear()
await restart()
})
afterEach(() => {
vi.restoreAllMocks()
clearMainWindowAgentStatusListeners()
server.stop()
rmSync(dir, { recursive: true, force: true })
})
async function restart() {
await server.start({ env: 'production', userDataPath: dir })
installMainWindowAgentStatusListeners({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only mocked WebContents is used; window is an unused option.
window: {} as BrowserWindow,
onRecordAgentState: () => {}
})
}
async function post(source: 'hermes' | 'codex' | 'antigravity', payload: Record<string, unknown>) {
expect((await postHookEvent(server, buildBody(payload), `/hook/${source}`)).status).toBe(204)
}
function frame() {
const call = bridge.send.mock.calls.findLast(([channel]) => channel === 'agentStatus:set')
if (!call) {
throw new Error('No main-window status publication')
}
return call[1]
}
async function positive(id = ROOT) {
await post('hermes', {
hook_event_name: 'pre_llm_call',
session_id: id,
platform: 'cli',
user_message: 'continue'
})
}
async function childDone() {
await post('hermes', { hook_event_name: 'pre_llm_call', session_id: CHILD, platform: 'subagent' })
await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD })
}
async function nested() {
await post('codex', {
hook_event_name: 'UserPromptSubmit',
session_id: 'nested-codex',
prompt: 'review'
})
expect(frame()).toMatchObject({
agentType: 'codex',
state: 'working',
providerSession: { id: 'nested-codex' }
})
}
async function tool(id = ROOT) {
await post('hermes', {
hook_event_name: 'post_tool_call',
session_id: id,
tool_name: 'delegate_task'
})
}
function expectReturned(id = ROOT, agent: 'hermes' | 'antigravity' = 'hermes') {
const data = frame()
expect(data).toMatchObject({
agentType: agent,
state: 'working',
providerSession: { id },
paneOwner: { agent, session: id }
})
expect(server.getStatusSnapshot()[0]?.providerSession?.id).toBe(id)
const tab = expectReturnedRenderer(data, id, agent, PANE, 'wt-1')
const status = resumeStatusFromHookRow(data, undefined)
if (!status) {
throw new Error('No host resume status')
}
const session = { ...getDefaultWorkspaceSession(), tabsByWorktree: { 'wt-1': [tab] } }
const checkpoint = hookStatusResumeCommand({
status,
session,
worktreeId: 'wt-1',
launchConfigOf: () => undefined
})
expect(checkpoint?.type === 'sleep' && checkpoint.records[0]?.providerSession.id).toBe(id)
expect(
quitResumeRecords({
statuses: [status],
session,
worktreeIdOf: () => 'wt-1',
launchConfigOf: () => undefined,
capturedAt: data.receivedAt
})[0]?.providerSession.id
).toBe(id)
expect(
hookRowPaneResumeRecord({ rows: [data], worktreeId: 'wt-1', tab })?.providerSession.id
).toBe(id)
}
it.each(['sessionless', 'replacement', 'positive-after-foreign'] as const)(
'settles the complete HTTP parent iteration after %s',
async (mode) => {
await post('hermes', {
hook_event_name: 'pre_tool_call',
session_id: ROOT,
tool_name: 'read_file'
})
expect(frame().paneOwner).toBeUndefined()
const id = mode === 'sessionless' ? ROOT : mode === 'replacement' ? CHILD : 'successor-C'
if (mode === 'replacement') {
await positive(ROOT)
await childDone()
await nested()
}
if (mode === 'positive-after-foreign') {
await post('hermes', { hook_event_name: 'on_session_end', session_id: ROOT })
await nested()
}
await positive(id)
expect(frame().paneOwner?.session).toBe(id)
// A different child avoids completing the renewed root when it was formerly a child itself.
await post('hermes', {
hook_event_name: 'pre_llm_call',
session_id: 'new-helper',
platform: 'subagent'
})
await post('hermes', { hook_event_name: 'on_session_end', session_id: 'new-helper' })
expect(frame().state).toBe('done')
await nested()
await tool('unrecognized-child')
expect(frame().agentType).toBe('codex')
await tool(id)
expectReturned(id)
if (mode === 'sessionless') {
await childDone()
await nested()
}
server.stop()
await restart()
await tool(id)
expectReturned(id)
}
)
it('keeps root-own completion terminal even when its binding matches', async () => {
await positive()
await post('hermes', { hook_event_name: 'post_llm_call', session_id: ROOT, platform: 'cli' })
await nested()
await tool()
expect(frame().agentType).toBe('codex')
})
it('retires the binding on an actual host clear, including through a later foreign row', async () => {
await positive()
await childDone()
server.ingestTerminalStatus({
paneKey: PANE,
worktreeId: 'wt-1',
origin: 'process',
payload: { agentType: 'hermes', state: 'working', prompt: '' }
})
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
expect(frame().paneOwner).toBeUndefined()
await post('hermes', { hook_event_name: 'on_session_end', session_id: CHILD })
await nested()
await tool()
expect(frame().agentType).toBe('codex')
server.stop()
await restart()
await tool()
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
await positive('fresh-C')
await childDone()
await nested()
await tool('fresh-C')
expectReturned('fresh-C')
server.recordLaunchResumeIdentity({
paneKey: PANE,
worktreeId: 'wt-1',
agentType: 'hermes',
providerSession: { key: 'session_id', id: 'explicit-seed' },
launchedAt: frame().receivedAt + 1
})
await tool('explicit-seed')
expectReturned('explicit-seed')
})
it.each([257, 512])(
'matches only the actual hook against a %s-character unproven legacy tuple after hydration',
async (length) => {
const id = 'l'.repeat(length)
await positive('b'.repeat(length))
expect(frame().providerSession?.id).toHaveLength(length)
await childDone()
await nested()
server.stop()
// Exact legacy representation: a helper ID with no positive observation of its own.
const path = join(dir, 'agent-hooks', 'last-status.json')
const saved = JSON.parse(readFileSync(path, 'utf8'))
saved.entries[PANE].agentPresence.session = id
writeFileSync(path, JSON.stringify(saved))
await restart()
await tool('different-session')
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
await tool(id)
expectReturned(id)
expect(readAgentProcessPresence({ agent: 'hermes', session: id })?.session).toBe(id)
expect(readAgentProcessPresence({ agent: 'antigravity', session: id })?.session).toBe(id)
expect(readAgentProcessPresence({ agent: 'codex', session: id })?.session).toBeUndefined()
}
)
it('reenters Antigravity from retained first-record authority when that file is temporarily unreadable', async () => {
const transcriptPath = join(dir, 'agy.jsonl')
const origin = {
step_index: 0,
source: 'USER_EXPLICIT',
type: 'USER_INPUT',
content: 'Reply with OK.'
}
const hook = (id: string, extra: Record<string, unknown> = {}) => ({
hook_event_name: 'PreInvocation',
conversationId: id,
transcriptPath,
lastUserInput: 'Reply with OK.',
...extra
})
writeFileSync(transcriptPath, `${JSON.stringify(origin)}\n`)
await post('antigravity', hook(ROOT))
await post(
'antigravity',
hook(ROOT, {
hook_event_name: 'Stop',
fullyIdle: true,
terminationReason: 'NO_TOOL_CALL',
error: ''
})
)
await nested()
const beforeRejectedRoot = server.getStatusSnapshot()
await post('antigravity', hook('rejected-C', { hook_event_name: 'SessionStart' }))
expect(server.getStatusSnapshot()).toEqual(beforeRejectedRoot)
rmSync(transcriptPath)
await post('antigravity', hook('child-A'))
expect(frame().agentType).toBe('codex')
await post('antigravity', hook(ROOT))
expectReturned(ROOT, 'antigravity')
writeFileSync(
transcriptPath,
`${JSON.stringify({ step_index: 0, source: 'SYSTEM', type: 'SYSTEM_MESSAGE', content: 'sender=parent' })}\n`
)
const cold = normalizeHookPayload(
createHookListenerState(),
'antigravity',
buildBody(hook('child-A')),
'production'
)
expect(cold?.providerSession).toBeUndefined()
})
it('forwards a cold host comparison to the retained canonical binding without creating one', async () => {
await positive()
await childDone()
await nested()
const build = (id: string) => {
const canonical = createHookListenerState()
const event = new RelayProviderHookNormalization().normalize(
canonical,
'hermes',
buildBody({ hook_event_name: 'post_tool_call', session_id: id, tool_name: 'delegate_task' }),
'production'
)
if (!event) {
throw new Error('No cold execution-host tool event')
}
return buildRelayHookEnvelope(event, 'hermes', 'production')
}
for (const id of ['child-A', ROOT]) {
const envelope = build(id)
expect(envelope.agentPresence).toEqual({ agent: 'hermes', session: id })
expect(envelope.providerSession).toBeUndefined()
server.ingestRemote(envelope, null)
expect(frame().agentType).toBe(id === ROOT ? 'hermes' : 'codex')
}
expectReturned()
server.clearPaneState(PANE)
server.ingestRemote(build('unknown-child'), null)
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
expect(frame().paneOwner).toBeUndefined()
})
it('rejects before mutating provider folds and validates scoped IDs with the locator parser', () => {
const now = 1_700_000_000_000
vi.spyOn(Date, 'now').mockReturnValue(now)
const state = createHookListenerState()
const previous = {
paneKey: PANE,
worktreeId: 'wt-1',
connectionId: null,
hostEvidenceObservedAt: now,
agentPresence: { agent: 'hermes', session: ROOT },
payload: { agentType: 'codex', state: 'working' as const, prompt: 'review' }
}
admitLegacyAgentStatus(
state,
'main-status-update',
previous,
AGENT_STATUS_2A_CURRENT_PRODUCER_MODE
)
const body = buildBody({
hook_event_name: 'pre_tool_call',
session_id: 'mismatch',
tool_name: 'write_file',
user_message: 'poison'
})
expect(normalizeHookPayload(state, 'hermes', body, 'production')).toBeNull()
expect(state.lastPromptByPaneKey.size).toBe(0)
expect(state.lastToolByPaneKey.size).toBe(0)
expect(state.hermesTurnByPaneKey.size).toBe(0)
for (const session of ['x'.repeat(513), '-option', 'bad\nidentity']) {
expect(readAgentProcessPresence({ agent: 'hermes', session })?.session).toBeUndefined()
expect(readAgentProcessPresence({ agent: 'antigravity', session })?.session).toBeUndefined()
}
})
it.each([
{ connectionId: 'other-host', worktreeId: 'wt-1', paneKey: PANE, tabId: 'tab-1' },
{ connectionId: null, worktreeId: 'other-worktree', paneKey: PANE, tabId: 'tab-1' },
{ connectionId: null, worktreeId: 'wt-1', paneKey: GOOD_PANE, tabId: 'tab-good' }
])('does not project a binding across terminal scope %j', async (scope) => {
await positive()
await childDone()
await nested()
const event = normalizeHookPayload(
createHookListenerState(),
'hermes',
buildBody({ hook_event_name: 'post_tool_call', session_id: ROOT, tool_name: 'delegate_task' }),
'production'
)
if (!event) {
throw new Error('Missing scope test tool')
}
server.ingestRemote({ ...buildRelayHookEnvelope(event, 'hermes'), ...scope }, scope.connectionId)
const rows = server.getStatusSnapshotForPane(scope.paneKey)
expect(rows[0]?.providerSession).toBeUndefined()
})
it('does not let positive authority bypass a different proven process owner', async () => {
const event = normalizeHookPayload(
createHookListenerState(),
'claude',
buildBody({ hook_event_name: 'UserPromptSubmit', session_id: 'claude-owner', prompt: 'work' }),
'production'
)
if (!event) {
throw new Error('Missing Claude owner event')
}
server.ingestRemote(
{
...event,
agentPresence: {
agent: 'claude',
sessionOwner: true,
process: { pid: 99, platform: 'linux', startTime: 'start' }
}
},
null
)
await positive('cannot-steal')
expect(server.getStatusSnapshot()[0]).toMatchObject({
agentType: 'claude',
providerSession: { id: 'claude-owner' }
})
})
@@ -315,7 +315,8 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges
event.origin ?? 'osc',
undefined,
mutationBefore,
newRun
newRun,
!preservedProviderSession
)
}
}
@@ -1,5 +1,6 @@
import { retainHookRootContext } from '../../../shared/agent-hook-listener/pane-provider-session'
import { deriveCliWriterTopology } from './cli-writer-observation'
import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition'
import { previewHookPresence } from '../../../shared/agent-hook-presence-transition'
import {
reconcileCodexStatusEvent,
markCodexLeadTurnInterrupted
@@ -50,24 +51,22 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
origin: AgentStatusObservationOrigin = 'hook',
observedAt?: number,
mutationBefore?: EnrichedAgentHookEventPayload,
newRun = false
newRun = false,
retireBinding = false
): EnrichedAgentHookEventPayload | undefined {
// Older relays can still forward a child's own session as the pane's resume identity.
const rootIdentityIncoming =
origin === 'hook' && incoming.payload.agentType === 'copilot' && incoming.toolAgentId
? { ...incoming, providerSession: undefined }
: incoming
const transitioned = transitionHookPresence(
rootIdentityIncoming,
this.state.lastStatusByPaneKey.get(incoming.paneKey)
const rootContext = previewHookPresence(
incoming,
this.state.lastStatusByPaneKey.get(incoming.paneKey),
origin === 'hook' || origin === 'launch',
retireBinding
)
const { event: rootIdentityIncoming, statusAdmissionSession } = rootContext
const transitioned = rootContext.transitioned
if (!transitioned) {
return undefined
}
const { authorityRestartId, codexRosterAuthority, ...payload } = {
...rootIdentityIncoming,
...transitioned
}
const enrichedTransition = { ...rootIdentityIncoming, ...transitioned }
const { authorityRestartId, codexRosterAuthority, ...payload } = enrichedTransition
if (!this.canWriteLegacyStatusRow(payload)) {
return undefined
}
@@ -123,13 +122,13 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
state: previous.payload.state,
updatedAt: previous.receivedAt,
restoredUnconfirmed: previous.restoredUnconfirmed,
agentPresence: previous.providerSessionOnly ? undefined : previous.agentPresence
agentPresence: previous.providerSessionOnly ? undefined : rootContext.owner
}
: undefined,
incoming: terminalOwnedPayload.payload.agentType,
// Relay presence is the execution host's owner verdict, not the sender's identity.
// Relay process presence is the host's owner verdict; scoped session bytes only compare this hook.
incomingProcess: payload.connectionId ? undefined : incoming.agentPresence?.process,
incomingSession: terminalOwnedPayload.providerSession?.id,
incomingSession: statusAdmissionSession,
now
})
if (
@@ -178,33 +177,14 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
previous?.payload,
codexRosterAuthority
)
const previousRoot =
previous &&
previous.payload.agentType === stateReconciledPayload.payload.agentType &&
((stateReconciledPayload.payload.agentType === 'codex' &&
stateReconciledPayload.toolAgentId) ||
(origin === 'hook' &&
stateReconciledPayload.payload.agentType === 'copilot' &&
this.sameTerminalOwner(previous, stateReconciledPayload)))
? previous
: undefined
const preservedProviderSession =
!stateReconciledPayload.providerSession && previousRoot?.providerSession
const preservedRootModel =
stateReconciledPayload.payload.agentType === 'codex' && !stateReconciledPayload.payload.model
? previousRoot?.payload.model
: undefined
// Relay restart loses root context; child hooks must not erase persisted resume/model identity.
const rootContextPreservingPayload =
preservedProviderSession || preservedRootModel
? {
...stateReconciledPayload,
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
payload: preservedRootModel
? { ...stateReconciledPayload.payload, model: preservedRootModel }
: stateReconciledPayload.payload
}
: stateReconciledPayload
const rootContextPreservingPayload = retainHookRootContext(
stateReconciledPayload,
previous,
origin === 'hook',
!!previous && this.sameTerminalOwner(previous, stateReconciledPayload),
rootContext.returningProviderSession
)
const identityResolvedPayload =
identity.agentType === rootContextPreservingPayload.payload.agentType
? rootContextPreservingPayload
@@ -42,6 +42,7 @@ export type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
export type PersistedAgentHookEventPayload = Omit<
EnrichedAgentHookEventPayload,
| 'authorityRestartId'
| 'statusAdmissionSession'
| 'launchToken'
| 'hostTurnRevision'
| 'promptInteractionKey'
@@ -0,0 +1,70 @@
import { expect } from 'vitest'
import type { HookSessionBindingRendererFixture } from '../../../../../shared/agent-hook-session-binding.test-fixture'
import { getAgentResumeArgv } from '../../../../../shared/agent-session-resume'
import { resumeRecordFromStatus } from '../../../../../shared/agent-resume-record-from-status'
import { createTestStore, makeTab } from '../store-test-helpers'
import { isInheritedFromActivePane } from '../../../hooks/ipc-events/agent-status-routing'
import { sleepingRecordFromEntry } from './agent-status-sleeping-records'
export const expectReturnedRenderer: HookSessionBindingRendererFixture['expectReturnedRenderer'] = (
data,
id,
agent,
paneKey,
worktreeId
) => {
// Both renderer admission paths consume the actual main-window publication over a foreign live row.
const store = createTestStore()
const tab = makeTab({ id: 'tab-1', worktreeId: worktreeId })
store.setState({ tabsByWorktree: { [worktreeId]: [tab] } })
store
.getState()
.setAgentStatus(
paneKey,
{ agentType: 'codex', state: 'working', prompt: 'review' },
undefined,
{
updatedAt: data.receivedAt - 1
}
)
expect(
isInheritedFromActivePane(store.getState().agentStatusByPaneKey[paneKey], data, agent, {
connectionId: data.connectionId,
worktreeId: data.worktreeId
})
).toBe(false)
store
.getState()
.setAgentStatus(
paneKey,
data,
undefined,
{ updatedAt: data.receivedAt },
{ connectionId: data.connectionId, worktreeId: data.worktreeId },
{ providerSession: data.providerSession, paneOwner: data.paneOwner }
)
const entry = store.getState().agentStatusByPaneKey[paneKey]
expect(entry).toMatchObject({ agentType: agent, providerSession: { id } })
const record = resumeRecordFromStatus({
entry,
worktreeId: worktreeId,
capturedAt: data.receivedAt
})
expect(record?.providerSession.id).toBe(id)
if (!record) {
throw new Error('No actual renderer resume record')
}
expect(getAgentResumeArgv(agent, record.providerSession)).toEqual(
agent === 'hermes' ? ['hermes', '--resume', id] : ['agy', '--conversation', id]
)
expect(store.getState().sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id).toBe(id)
expect(
sleepingRecordFromEntry({
state: store.getState(),
entry,
worktreeId: worktreeId,
capturedAt: data.receivedAt
})?.providerSession.id
).toBe(id)
return tab
}
@@ -483,7 +483,7 @@ describe('shared agent-hook-listener', () => {
expect(lateTool).toBeNull()
})
// Event order captured from agy 1.4.7 running an in-process invoke_subagent (#17650).
// Event order captured from agy 1.4.7 (#17650); transcript contents below are synthetic, not origin evidence.
it('keeps an Antigravity parent working until its own idle Stop when a subagent finishes', () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'orca-antigravity-subagent-'))
const writeTranscript = (name: string, request: string, reply: string): string => {
@@ -340,6 +340,6 @@ describe('shared agent-hook-listener', () => {
'production'
)?.providerSession?.id ?? null
)
expect(sessions).toEqual(Array(10).fill('local-parent'))
expect(sessions).toEqual(['local-parent', 'local-parent', ...Array(8).fill(null)])
})
})
+25 -35
View File
@@ -1,6 +1,5 @@
import { readHookAgentProcess } from './agent-process-presence'
import { resolveAgentStatusIdentity } from './agent-status-identity'
import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner'
import { isInheritedHookStatus } from './agent-hook-presence-transition'
import { normalizeAgentStatusPayload, type AgentMainAgentStatus } from './agent-status-types'
import type { AgentHookSource } from './agent-hook-relay'
import {
@@ -28,7 +27,10 @@ import {
} from './agent-hook-listener/opencode-session-registry'
import { claudeRowHasUnlistedLiveWork } from './agent-hook-listener/providers/claude-pane-hold-evidence'
import { readString } from './agent-hook-listener/tool-input-preview'
import { readPaneProviderSession } from './agent-hook-listener/pane-provider-session'
import {
hasSeparateHookSessionAdmission,
readPaneSessionContext
} from './agent-hook-listener/pane-provider-session'
/** Canonical transport-agnostic normalization entry shared by main and relay listeners. */
const CLAUDE_EXIT_SESSION_END_REASONS = new Set([
'prompt_input_exit',
@@ -45,6 +47,7 @@ export function normalizeHookPayload(
options: {
deferCompactOwnershipToClient?: boolean
previousOpenCodeMainAgent?: AgentMainAgentStatus
bindingStatusByPaneKey?: ReadonlyMap<string, AgentHookEventPayload>
admitOpenCodeTui?: (
identity: Pick<
AgentHookEventPayload,
@@ -59,6 +62,7 @@ export function normalizeHookPayload(
}
const {
record,
eventName,
paneKey: stampedPaneKey,
hookPayloadRecord,
tabId: stampedTabId,
@@ -68,13 +72,8 @@ export function normalizeHookPayload(
if (source === 'claude') {
state.claudeUnconfirmedRestoredStatusPaneKeys.delete(stampedPaneKey)
}
const eventName =
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
hookPayloadRecord.hook_event_name ??
hookPayloadRecord.hookEventName ??
// Only jcode posts lifecycle under event; other providers may use that field for unrelated data.
(source === 'jcode' ? hookPayloadRecord.event : undefined)
const providerSession = readPaneProviderSession(state, source, stampedPaneKey, hookPayloadRecord)
const sessions = readPaneSessionContext(state, source, envelope, eventName)
const { providerSession, statusAdmissionSession } = sessions
if (source === 'opencode' && record.opencodeTui === 1 && !providerSession) {
return null
}
@@ -143,33 +142,23 @@ export function normalizeHookPayload(
}
const previousStatus = state.lastStatusByPaneKey.get(paneKey)
const agentProcess = readHookAgentProcess(source, record.agentProcess)
const previousReceivedAt =
previousStatus &&
'receivedAt' in previousStatus &&
typeof previousStatus.receivedAt === 'number'
? previousStatus.receivedAt
: previousStatus?.hostEvidenceObservedAt
// Reject before provider folds mutate the pane's prompt, tool and child caches.
const admissionStatus =
hasSeparateHookSessionAdmission(source) && options.bindingStatusByPaneKey
? options.bindingStatusByPaneKey.get(paneKey)
: previousStatus
if (
previousStatus &&
previousReceivedAt !== undefined &&
sameAgentStatusTerminalOwner(
{ connectionId: previousStatus.connectionId ?? null, worktreeId: previousStatus.worktreeId },
{ connectionId: null, worktreeId }
) &&
resolveAgentStatusIdentity({
existing: {
agentType: previousStatus.payload.agentType,
state: previousStatus.payload.state,
updatedAt: previousReceivedAt,
restoredUnconfirmed: previousStatus.restoredUnconfirmed,
agentPresence: previousStatus.providerSessionOnly ? undefined : previousStatus.agentPresence
isInheritedHookStatus(
{
paneKey,
worktreeId,
connectionId: null,
providerSession: providerSession ?? undefined,
statusAdmissionSession,
agentPresence: agentProcess ? { agent: source, process: agentProcess } : undefined,
payload: { agentType: source, state: 'working', prompt: '' }
},
incoming: source,
incomingProcess: agentProcess,
incomingSession: providerSession?.id,
now: Date.now()
}).inheritedFromActivePane
admissionStatus
)
) {
return null
}
@@ -308,6 +297,7 @@ export function normalizeHookPayload(
}
: {}),
...(providerSession ? { providerSession } : {}),
...(statusAdmissionSession ? { statusAdmissionSession } : {}),
...(providerSessionOnly ? { providerSessionOnly: true } : {}),
...dispatched.hostTurnSignals,
payload: transportPayload
@@ -6,6 +6,7 @@ import { parsePaneKey } from '../stable-pane-id'
import { MAX_PANE_KEY_LEN, warnOnHookEnvOrVersionMismatch } from './listener-limits'
import type { HookListenerState } from './listener-state'
import { parseAgentHookJson } from './request-body'
import { readFirstString } from './interactive-tool'
function readHookHeader(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
@@ -100,6 +101,7 @@ export type ParsedHookEnvelope = {
record: Record<string, unknown>
paneKey: string
hookPayloadRecord: Record<string, unknown>
eventName: unknown
tabId?: string
worktreeId?: string
launchToken?: string
@@ -152,9 +154,16 @@ export function parseHookEnvelope(
if (tabId && tabId !== parsedPaneKey.tabId) {
return null
}
const eventName =
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
('hook_event_name' in hookPayload ? hookPayload.hook_event_name : undefined) ??
('hookEventName' in hookPayload ? hookPayload.hookEventName : undefined) ??
// Only jcode uses event for lifecycle; other providers may use it for unrelated data.
(source === 'jcode' && 'event' in hookPayload ? hookPayload.event : undefined)
return {
record,
paneKey,
eventName,
hookPayloadRecord: hookPayload as Record<string, unknown>,
tabId,
worktreeId: readEnvelopeString(record, 'worktreeId'),
@@ -51,6 +51,8 @@ export type AgentHookEventPayload = {
toolAgentType?: string
/** Provider-owned conversation/session id needed to resume a sleeping agent. */
providerSession?: AgentProviderSessionMetadata
/** In-process status correlation only; never a new locator, persisted field or relay field. */
statusAdmissionSession?: string
/** Session identity update with no turn-state transition; refreshes durable resume metadata without a fake status row. */
providerSessionOnly?: boolean
/** True when this event is a relay cache replay rather than a live hook. */
@@ -7,16 +7,45 @@ import {
} from '../agent-session-resume'
import { readFirstString } from './interactive-tool'
import type { HookListenerState } from './listener-state'
import type { AgentHookEventPayload } from './listener-event'
import { isGrokSubagentEvent } from './providers/grok-tool-fields'
import { withHermesPaneSessionId } from './providers/hermes-events'
import { isHermesRootSessionEvent, withHermesPaneSessionId } from './providers/hermes-events'
import { readString } from './tool-input-preview'
import { readFirstTranscriptRecord } from './transcript-reader'
export function hasSeparateHookSessionAdmission(
source: string | undefined
): source is 'hermes' | 'antigravity' {
return source === 'hermes' || source === 'antigravity'
}
export function readPaneSessionContext(
state: HookListenerState,
source: AgentHookSource,
envelope: { paneKey: string; hookPayloadRecord: Record<string, unknown> },
eventName: unknown
) {
const { paneKey, hookPayloadRecord } = envelope
return {
providerSession: readPaneProviderSession(state, source, paneKey, hookPayloadRecord, eventName),
statusAdmissionSession: hasSeparateHookSessionAdmission(source)
? extractAgentProviderSession(
source,
source === 'hermes'
? withHermesPaneSessionId(state, paneKey, hookPayloadRecord)
: hookPayloadRecord
)?.id
: undefined
}
}
/** The provider session a hook names for its pane; child agents' hooks never name their own. */
export function readPaneProviderSession(
function readPaneProviderSession(
state: HookListenerState,
source: AgentHookSource,
paneKey: string,
hookPayload: Record<string, unknown>
hookPayload: Record<string, unknown>,
eventName: unknown
): AgentProviderSessionMetadata | null {
if (
source === 'copilot' &&
@@ -32,13 +61,32 @@ export function readPaneProviderSession(
const previous = state.lastStatusByPaneKey.get(paneKey)
return previous?.source === 'grok' ? (previous.providerSession ?? null) : null
}
const providerSession = extractAgentProviderSession(
source,
source === 'hermes' ? withHermesPaneSessionId(state, paneKey, hookPayload) : hookPayload
)
if (
source === 'hermes' &&
(!isHermesRootSessionEvent(eventName) ||
typeof hookPayload.platform !== 'string' ||
!['cli', 'tui', 'desktop', 'acp'].includes(hookPayload.platform))
) {
return null
}
const providerSession = extractAgentProviderSession(source, hookPayload)
if (!providerSession) {
return null
}
if (source === 'antigravity') {
const transcriptPath = readPathFromKeys(hookPayload, ['transcriptPath', 'transcript_path'])
const origin = transcriptPath ? readFirstTranscriptRecord(transcriptPath) : undefined
if (
typeof origin !== 'object' ||
origin === null ||
!('source' in origin) ||
origin.source !== 'USER_EXPLICIT' ||
!('type' in origin) ||
origin.type !== 'USER_INPUT'
) {
return null
}
}
const continued = continuedProviderSession(
state,
source,
@@ -105,3 +153,75 @@ function readClaudeEffort(
: undefined
return reported ?? continued?.effort
}
/** A status correlation may reuse an established owner without publishing a new restore locator. */
function readHookStatusSession(incoming: AgentHookEventPayload): string | undefined {
return (
incoming.statusAdmissionSession ??
incoming.providerSession?.id ??
(hasSeparateHookSessionAdmission(incoming.agentPresence?.agent) &&
incoming.agentPresence?.agent === incoming.payload.agentType &&
!incoming.agentPresence?.ended
? incoming.agentPresence?.session
: undefined)
)
}
/** Older normalized relays retain some event kinds even after losing the raw ancestry fields. */
export function readHookSessionContext<T extends AgentHookEventPayload>(
incoming: T,
fromHook: boolean
) {
const { statusAdmissionSession: _statusAdmissionSession, ...event } = incoming
// Relays use scoped session bytes only to compare this hook, never to initialize ownership.
const ownershipEvent =
fromHook && event.agentPresence && hasSeparateHookSessionAdmission(event.agentPresence.agent)
? { ...event, agentPresence: { ...event.agentPresence, session: undefined } }
: event
const rootEvent =
fromHook &&
((event.payload.agentType === 'copilot' && event.toolAgentId) ||
(event.payload.agentType === 'hermes' &&
event.hookEventName !== undefined &&
!isHermesRootSessionEvent(event.hookEventName)))
? { ...ownershipEvent, providerSession: undefined }
: ownershipEvent
return {
event: rootEvent,
statusAdmissionSession:
fromHook && hasSeparateHookSessionAdmission(incoming.payload.agentType)
? readHookStatusSession(incoming)
: rootEvent.providerSession?.id
}
}
export function retainHookRootContext(
incoming: AgentHookEventPayload,
previous: AgentHookEventPayload | undefined,
fromHook: boolean,
sameTerminalOwner: boolean,
returningProviderSession: AgentProviderSessionMetadata | undefined
): AgentHookEventPayload {
const previousRoot =
previous &&
previous.payload.agentType === incoming.payload.agentType &&
((incoming.payload.agentType === 'codex' && incoming.toolAgentId) ||
(fromHook &&
['copilot', 'hermes', 'antigravity'].includes(incoming.payload.agentType ?? '') &&
sameTerminalOwner))
? previous
: undefined
const providerSession =
!incoming.providerSession && (previousRoot?.providerSession ?? returningProviderSession)
const model =
incoming.payload.agentType === 'codex' && !incoming.payload.model
? previousRoot?.payload.model
: undefined
return providerSession || model
? {
...incoming,
...(providerSession ? { providerSession } : {}),
payload: model ? { ...incoming.payload, model } : incoming.payload
}
: incoming
}
@@ -20,6 +20,14 @@ const HERMES_EVENT_STATES: Record<string, 'working' | 'waiting' | 'done'> = {
post_approval_response: 'working'
}
export function isHermesRootSessionEvent(eventName: unknown): boolean {
return (
eventName === 'on_session_start' ||
eventName === 'on_session_reset' ||
eventName === 'pre_llm_call'
)
}
// Why: Hermes' post-turn memory/skill review fires tool hooks with no turn of its own.
function admitsHermesEvent(
state: HookListenerState,
@@ -52,7 +60,7 @@ function admitsHermesEvent(
return !(isToolEvent && turn?.ended)
}
/** The hook payload with a `delegate_task` child's session id replaced by its parent pane's. */
/** Status admission compares warm children with their parent; this never supplies restore identity. */
export function withHermesPaneSessionId(
state: HookListenerState,
paneKey: string,
@@ -1,7 +1,66 @@
import { describe, expect, it } from 'vitest'
import { findLastExtractedTranscriptLineText } from './transcript-reader'
import {
closeSync,
fstatSync,
mkdtempSync,
openSync,
readSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type * as NodeFs from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import {
findLastExtractedTranscriptLineText,
readFirstTranscriptRecord,
TRANSCRIPT_CHUNK_BYTES
} from './transcript-reader'
import { extractAssistantTextFromLine } from './transcript-entry-text'
vi.mock('node:fs', async (importOriginal) => {
const fs = await importOriginal<typeof NodeFs>()
return {
...fs,
openSync: vi.fn(fs.openSync),
readSync: vi.fn(fs.readSync),
closeSync: vi.fn(fs.closeSync)
}
})
it('reads the same bounded first-record prefix with N and 10N later records and closes the file', () => {
const dir = mkdtempSync(join(tmpdir(), 'transcript-first-record-'))
const transcriptPath = join(dir, 'transcript.jsonl')
const first = {
source: 'USER_EXPLICIT',
type: 'USER_INPUT',
content: 'x'.repeat(TRANSCRIPT_CHUNK_BYTES)
}
const later = `${JSON.stringify({ source: 'SYSTEM', content: 'y'.repeat(512) })}\n`
try {
for (const records of [1000, 10_000]) {
writeFileSync(transcriptPath, `${JSON.stringify(first)}\n${later.repeat(records)}`)
vi.mocked(openSync).mockClear()
vi.mocked(readSync).mockClear()
vi.mocked(closeSync).mockClear()
// Call only the origin reader, excluding the unrelated reverse/prompt reader's IO.
expect(readFirstTranscriptRecord(transcriptPath)).toEqual(first)
expect(openSync).toHaveBeenCalledExactlyOnceWith(transcriptPath, 'r')
expect(vi.mocked(readSync).mock.results.map((result) => result.value)).toEqual([
TRANSCRIPT_CHUNK_BYTES,
TRANSCRIPT_CHUNK_BYTES
])
const fd = vi.mocked(openSync).mock.results[0].value
expect(closeSync).toHaveBeenCalledExactlyOnceWith(fd)
expect(() => fstatSync(fd)).toThrow(expect.objectContaining({ code: 'EBADF' }))
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
function expectedLines(text: string): string[] {
return text
.split('\n')
@@ -1,8 +1,42 @@
import { closeSync, openSync, readSync } from 'node:fs'
import { parseAgentHookJson } from './request-body'
import { extractAssistantTextFromLine } from './transcript-entry-text'
import { scanFileRegionsBackward } from './reverse-file-region-scan'
export const TRANSCRIPT_CHUNK_BYTES = 64 * 1024
export const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024
/** Read origin evidence from the first record only; later user records cannot establish root ownership. */
export function readFirstTranscriptRecord(transcriptPath: string): unknown {
try {
const fd = openSync(transcriptPath, 'r')
try {
const chunks: Buffer[] = []
let bytesRead = 0
while (bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
const buffer = Buffer.alloc(
Math.min(TRANSCRIPT_CHUNK_BYTES, TRANSCRIPT_MAX_SCAN_BYTES - bytesRead)
)
const count = readSync(fd, buffer, 0, buffer.length, bytesRead)
if (count === 0) {
return parseAgentHookJson(Buffer.concat(chunks).toString('utf8'))
}
bytesRead += count
const chunk = buffer.subarray(0, count)
const newline = chunk.indexOf(10)
chunks.push(newline === -1 ? chunk : chunk.subarray(0, newline))
if (newline !== -1) {
return parseAgentHookJson(Buffer.concat(chunks).toString('utf8'))
}
}
} finally {
closeSync(fd)
}
} catch {
// Missing, partial or unreadable origin is re-observed on the next ordinary hook.
}
return undefined
}
export function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined {
return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine)
}
+103 -5
View File
@@ -1,17 +1,111 @@
import type { AgentHookEventPayload } from './agent-hook-listener/listener-event'
import { isSameAgentProcess } from './agent-process-presence'
import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner'
import {
hasSeparateHookSessionAdmission,
readHookSessionContext
} from './agent-hook-listener/pane-provider-session'
import { extractAgentProviderSession } from './agent-session-resume'
import { resolveAgentStatusIdentity } from './agent-status-identity'
/** A pane has one owning agent; only the owner's own proven process can end it. */
export function transitionHookPresence(
/** Refuse before provider folds can mutate prompt, tool or child state. */
export function isInheritedHookStatus(
incoming: AgentHookEventPayload,
previous: AgentHookEventPayload | undefined
): AgentHookEventPayload | undefined {
): boolean {
const receivedAt =
previous && 'receivedAt' in previous && typeof previous.receivedAt === 'number'
? previous.receivedAt
: previous?.hostEvidenceObservedAt
if (!previous || receivedAt === undefined || !sameAgentStatusTerminalOwner(previous, incoming)) {
return false
}
const preview = previewHookPresence(incoming, previous)
return resolveAgentStatusIdentity({
existing: {
agentType: previous.payload.agentType,
state: previous.payload.state,
updatedAt: receivedAt,
restoredUnconfirmed: previous.restoredUnconfirmed,
agentPresence: previous.providerSessionOnly ? undefined : preview.owner
},
incoming: incoming.payload.agentType,
incomingProcess: incoming.agentPresence?.process,
incomingSession: preview.statusAdmissionSession,
now: Date.now()
}).inheritedFromActivePane
}
/** Preview an existing association before admission; only the accepted event commits this result. */
export function previewHookPresence<T extends AgentHookEventPayload>(
incoming: T,
previous: AgentHookEventPayload | undefined,
fromHook = true,
retireBinding = false
) {
const context = readHookSessionContext(incoming, fromHook)
if (previous && !sameAgentStatusTerminalOwner(previous, incoming)) {
previous = undefined
}
const recorded = previous?.agentPresence
const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined
const recordedAgent = recorded?.agent
const scopedSource = hasSeparateHookSessionAdmission(recordedAgent) ? recordedAgent : undefined
let owner = recorded
let statusAdmissionSession = context.statusAdmissionSession
if (scopedSource && retireBinding && recorded) {
owner = { ...recorded, session: undefined }
} else if (
fromHook &&
scopedSource &&
recorded &&
!recorded.ended &&
!recorded.process &&
recorded.agent === incoming.payload.agentType &&
context.event.providerSession
) {
owner = { ...recorded, session: context.event.providerSession.id }
statusAdmissionSession = context.event.providerSession.id
}
const previousBinding =
previous && owner !== recorded ? { ...previous, agentPresence: owner } : previous
const matched = Boolean(
fromHook &&
scopedSource &&
owner?.session &&
!owner.ended &&
owner.agent === incoming.payload.agentType &&
owner.session === statusAdmissionSession &&
(!owner.process ||
(incoming.agentPresence?.process &&
isSameAgentProcess(owner.process, incoming.agentPresence.process)))
)
return {
...context,
owner,
statusAdmissionSession,
transitioned: transitionHookPresence(context.event, previousBinding),
returningProviderSession:
matched && owner && scopedSource
? (extractAgentProviderSession(scopedSource, {
session_id: owner.session,
conversationId: owner.session
}) ?? undefined)
: undefined
}
}
/** A pane has one owning agent; only the owner's own proven process can end it. */
function transitionHookPresence(
incoming: AgentHookEventPayload,
previous: AgentHookEventPayload | undefined
): AgentHookEventPayload | undefined {
const recorded = previous?.agentPresence
const owner =
recorded &&
!recorded.ended &&
(!previous?.providerSessionOnly || hasSeparateHookSessionAdmission(recorded.agent))
? recorded
: undefined
const sender = incoming.agentPresence?.process
// Only admitted process-ending SessionEnd or host-proved exits mark ended; other SessionEnd events update status.
const exit = incoming.agentPresence?.ended === true
@@ -50,7 +144,11 @@ export function transitionHookPresence(
if (!agent || agent === 'unknown') {
return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming
}
const session = incoming.agentPresence?.session ?? incoming.providerSession?.id
const session = hasSeparateHookSessionAdmission(agent)
? agent === incoming.payload.agentType
? incoming.providerSession?.id
: undefined
: (incoming.agentPresence?.session ?? incoming.providerSession?.id)
return {
...incoming,
agentPresence: {
@@ -0,0 +1,12 @@
import type { AgentStatusIpcPayload } from './agent-status-types'
import type { TerminalTab } from './terminal-tab-types'
export type HookSessionBindingRendererFixture = {
expectReturnedRenderer: (
data: AgentStatusIpcPayload,
id: string,
agent: 'hermes' | 'antigravity',
paneKey: string,
worktreeId: string
) => TerminalTab
}
+9 -3
View File
@@ -1,5 +1,6 @@
import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization'
import type { AgentType } from './agent-status-types'
import { extractAgentProviderSession } from './agent-session-resume'
export type AgentProcessIdentity = {
pid: number
@@ -73,9 +74,14 @@ export function readAgentProcessPresence(value: unknown): AgentProcessPresence |
}
const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined
const session =
'session' in value && typeof value.session === 'string' && value.session.length <= 256
? value.session
: undefined
value.agent === 'hermes' || value.agent === 'antigravity'
? extractAgentProviderSession(value.agent, {
session_id: 'session' in value ? value.session : undefined,
conversationId: 'session' in value ? value.session : undefined
})?.id
: 'session' in value && typeof value.session === 'string' && value.session.length <= 256
? value.session
: undefined
return {
agent: value.agent,
...(process ? { process } : {}),
+7 -1
View File
@@ -1,6 +1,7 @@
// Build SSH hook envelopes from parsed events/raw bodies; publication owns shedding and redelivery.
import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event'
import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay'
import { hasSeparateHookSessionAdmission } from '../shared/agent-hook-listener/pane-provider-session'
import { pickHostTurnEnd } from '../shared/agent-status-types'
// Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly.
@@ -18,7 +19,12 @@ export function buildRelayHookEnvelope(
...(event.hostEvidenceObservedAt !== undefined
? { evidenceAgeMs: Math.max(0, Date.now() - event.hostEvidenceObservedAt) }
: {}),
agentPresence: event.agentPresence,
agentPresence: hasSeparateHookSessionAdmission(source)
? {
agent: source,
session: event.statusAdmissionSession
}
: event.agentPresence,
paneKey: event.paneKey,
...(event.hostTurnRevision ? { hostTurnRevision: event.hostTurnRevision } : {}),
...(event.launchToken ? { launchToken: event.launchToken } : {}),
+6 -2
View File
@@ -1,4 +1,4 @@
import { transitionHookPresence } from '../shared/agent-hook-presence-transition'
import { previewHookPresence } from '../shared/agent-hook-presence-transition'
import { hasOtherAgentProcessOwner, isSameAgentProcess } from '../shared/agent-process-presence'
import { sameAgentStatusTerminalOwner } from '../shared/agent-status-terminal-owner'
import { cacheRelayLegacyAgentStatus } from '../shared/agent-status-legacy-relay-cache'
@@ -53,7 +53,11 @@ export function applyRelayHookEvent(
if (cancellation.hold) {
return previous
}
const transitioned = transitionHookPresence(cancellation.event, previous)
const preview = previewHookPresence(cancellation.event, previous)
const transitioned = preview.transitioned && {
...preview.transitioned,
statusAdmissionSession: incoming.statusAdmissionSession
}
if (!transitioned) {
return undefined
}
@@ -39,7 +39,10 @@ export class RelayProviderHookNormalization {
lastToolByPaneKey: state.lastToolByPaneKey,
lastStatusByPaneKey: state.lastStatusByPaneKey
}
const event = normalizeHookPayload(view, source, body, env, options)
const event = normalizeHookPayload(view, source, body, env, {
...options,
bindingStatusByPaneKey: canonical.lastStatusByPaneKey
})
if (event) {
const providerState = state
cacheRelayLegacyAgentStatus(state, event, MAX_CACHED_PANES, (paneKey) =>