fix(agent-status): correlate manual Claude compact hooks (#12332)

Co-authored-by: gatsby74 <166927047+gatsby74@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong
2026-08-04 15:03:57 -07:00
committed by GitHub
co-authored by gatsby74 OrcaWin
parent d52df52eea
commit 847c8c852d
14 changed files with 1324 additions and 49 deletions
+114
View File
@@ -2338,6 +2338,120 @@
],
"demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one."
},
{
"id": "agent-status.manual-compact-identity",
"title": "Manual Claude compact completion retires only its exact status generation",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session",
"layer": "shared-main-relay-contract",
"surfaces": [
"Claude status hooks",
"manual compact lifecycle",
"last-status persistence",
"SSH relay ingest"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11352",
"https://github.com/stablyai/orca/pull/11353"
],
"invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.",
"oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
"Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id"
],
"testFiles": [
"src/shared/manual-compact-prompt-identity.test.ts",
"src/main/agent-hooks/manual-compact-hook-stream.test.ts",
"src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
"src/shared/agent-hook-listener.test.ts",
"src/shared/agent-hook-relay.test.ts",
"src/relay/agent-hook-server.test.ts",
"src/main/agent-hooks/server.test.ts",
"src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts"
],
"assertionRefs": [
{
"file": "src/shared/manual-compact-prompt-identity.test.ts",
"assertions": [
"matching manual identity settles once",
"stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed",
"4,097 rejected pane keys allocate no ownership state"
]
},
{
"file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
"assertions": [
"local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities",
"later provider work and wrong transport identity remain authoritative",
"persisted PreCompact restores its prompt"
]
},
{
"file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
"assertions": ["pane, tab, and server cleanup revoke compact completion authority"]
},
{
"file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts",
"assertions": [
"SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-02",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 2.66,
"summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage."
},
{
"date": "2026-08-02",
"runner": "manual",
"platform": "macos",
"command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id",
"result": "passed",
"durationSeconds": 120,
"summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "shared, main-process, relay, persistence, and fake-SSH contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate with one local run; CI soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical oracle (SHA-256 24e80c15b467ffa9c47a81099f11bf26dea71293e47b5d950b2fad1497094764) failed 5 of 6 on origin/main@a20165a43d, passed 6 of 6 on the candidate, failed 3 of 6 when only the exact-transition guard was disabled, and passed 6 of 6 after restoration."
},
"performanceBudget": {
"required": true,
"evidence": "Each compact event performs one O(1) current-status lookup plus bounded UUID, source, connection, session, and event comparisons. Identity lives on the existing status row and is removed by its existing lifecycle; the change adds no owner map, global scan, polling, timer, subprocess, network request, or listener. The 4,097-key adversarial arm retains zero status, prompt, or ownership entries."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Run live manual compact journeys on Linux and Windows and through a real SSH target.",
"Keep automatic compact completion disabled until a generation-unique provider identity is proven."
],
"knownGaps": [
"No live Linux, Windows, WSL, paired-runtime, or real-SSH manual compact journey was run.",
"Claude versions before prompt_id support intentionally cannot complete a manual compact row through PostCompact.",
"Automatic Claude and Kimi completion remain intentionally fail-closed because their hooks do not prove an exact compact generation."
],
"demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug."
},
{
"id": "agent-session.provider-ownership",
"title": "Provider sessions are resumed once per workspace ownership claim",
@@ -0,0 +1,367 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RelayAgentHookServer } from '../../relay/agent-hook-server'
import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111')
const PROMPT_ID_1 = '22222222-2222-4222-8222-222222222222'
const PROMPT_ID_2 = '33333333-3333-4333-8333-333333333333'
const SESSION = { key: 'session_id' as const, id: 'session-a' }
type ClaudeHook = {
hook_event_name: string
prompt?: string
prompt_id: string
session_id: string
trigger?: 'manual' | 'auto'
agent_id?: string
agent_type?: string
}
function claudeHook(
hookEventName: string,
promptId: string,
extra: Partial<ClaudeHook> = {}
): ClaudeHook {
return {
hook_event_name: hookEventName,
prompt_id: promptId,
session_id: 'session-a',
...extra
}
}
function manualEnvelope(hookEventName: string, state: 'working' | 'done') {
return {
source: 'claude' as const,
paneKey: PANE_KEY,
hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
hookEventName,
providerPromptId: hookEventName === 'UserPromptSubmit' ? PROMPT_ID_2 : PROMPT_ID_1,
compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const),
providerSession: SESSION,
payload: { state, prompt: 'work before compact', agentType: 'claude' as const }
}
}
function postHook(port: number, token: string, payload: Record<string, unknown>) {
return fetch(`http://127.0.0.1:${port}/hook/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({ paneKey: PANE_KEY, payload })
})
}
describe('manual Claude compact hook stream', () => {
const servers: { stop: () => void }[] = []
const temporaryPaths: string[] = []
afterEach(() => {
for (const server of servers.splice(0)) {
server.stop()
}
for (const path of temporaryPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
it('settles an exact local HTTP lifecycle and rejects a duplicate completion', async () => {
const server = new AgentHookServer()
servers.push(server)
await server.start({ env: 'production' })
const env = server.buildPtyEnv()
const events: string[] = []
const unsubscribe = server.subscribeEnrichedStatus((event) => {
events.push(`${event.hookEventName}:${event.payload.state}`)
})
await postHook(
Number(env.ORCA_AGENT_HOOK_PORT),
env.ORCA_AGENT_HOOK_TOKEN,
claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
)
await postHook(
Number(env.ORCA_AGENT_HOOK_PORT),
env.ORCA_AGENT_HOOK_TOKEN,
claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
)
await postHook(
Number(env.ORCA_AGENT_HOOK_PORT),
env.ORCA_AGENT_HOOK_TOKEN,
claudeHook('SubagentStart', PROMPT_ID_1, {
agent_id: 'compact-agent',
agent_type: 'general-purpose'
})
)
await postHook(
Number(env.ORCA_AGENT_HOOK_PORT),
env.ORCA_AGENT_HOOK_TOKEN,
claudeHook('SubagentStop', PROMPT_ID_1, {
agent_id: 'compact-agent',
agent_type: 'general-purpose'
})
)
await postHook(
Number(env.ORCA_AGENT_HOOK_PORT),
env.ORCA_AGENT_HOOK_TOKEN,
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
)
await postHook(
Number(env.ORCA_AGENT_HOOK_PORT),
env.ORCA_AGENT_HOOK_TOKEN,
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
)
expect(events).toEqual([
'UserPromptSubmit:working',
'PreCompact:working',
'SubagentStart:working',
'SubagentStop:working',
'PostCompact:done'
])
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'work before compact',
agentType: 'claude'
})
])
unsubscribe()
})
it('preserves the manual identity over relay and rejects stale transport identities', async () => {
const main = new AgentHookServer()
const forwarded: AgentHookRelayEnvelope[] = []
const emitted: string[] = []
const connectionId = 'conn-a'
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-relay-'))
temporaryPaths.push(endpointDir)
const relay = new RelayAgentHookServer({
endpointDir,
token: 'manual-compact-token',
forward: (envelope) => {
forwarded.push(envelope)
main.ingestRemote(envelope, connectionId)
}
})
servers.push(main, relay)
const unsubscribe = main.subscribeEnrichedStatus((event) => {
emitted.push(`${event.hookEventName}:${event.payload.state}`)
})
await relay.start({ publishEndpoint: false })
const coordinates = relay.getCoordinates()
await postHook(
coordinates.port,
coordinates.token,
claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
)
// A restarted relay has no prior listener cache; main still owns the status boundary.
relay.clearPaneState(PANE_KEY)
await postHook(
coordinates.port,
coordinates.token,
claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
)
const validPost = {
...forwarded.at(-1)!,
hookEventName: 'PostCompact',
payload: {
state: 'done' as const,
prompt: 'work before compact',
agentType: 'claude' as const
}
}
main.ingestRemote({ ...validPost, providerPromptId: PROMPT_ID_2 }, 'conn-a')
main.ingestRemote({ ...validPost, providerPromptId: undefined }, 'conn-a')
main.ingestRemote({ ...validPost, providerSession: undefined }, 'conn-a')
main.ingestRemote({ ...validPost, source: 'codex' }, 'conn-a')
main.ingestRemote(validPost, 'conn-b')
expect(emitted).toEqual(['UserPromptSubmit:working', 'PreCompact:working'])
expect(main.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'work before compact'
})
await postHook(
coordinates.port,
coordinates.token,
claudeHook('SubagentStart', PROMPT_ID_1, {
agent_id: 'compact-agent',
agent_type: 'general-purpose'
})
)
await postHook(
coordinates.port,
coordinates.token,
claudeHook('SubagentStop', PROMPT_ID_1, {
agent_id: 'compact-agent',
agent_type: 'general-purpose'
})
)
await postHook(
coordinates.port,
coordinates.token,
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
)
expect(forwarded.at(-1)).toMatchObject({
source: 'claude',
providerPromptId: PROMPT_ID_1,
compactTrigger: 'manual',
providerSession: SESSION,
hookEventName: 'PostCompact'
})
expect(emitted.at(-1)).toBe('PostCompact:done')
expect(emitted.slice(-3)).toEqual([
'SubagentStart:working',
'SubagentStop:working',
'PostCompact:done'
])
expect(main.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'work before compact'
})
unsubscribe()
})
it('invalidates a compact completion when later provider work owns the pane', () => {
const server = new AgentHookServer()
servers.push(server)
server.ingestRemote(manualEnvelope('UserPromptSubmit', 'working'), 'conn-a')
server.ingestRemote(manualEnvelope('PreCompact', 'working'), 'conn-a')
server.ingestRemote(
{
source: 'codex',
paneKey: PANE_KEY,
hookEventName: 'UserPromptSubmit',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'new work', agentType: 'codex' }
},
'conn-a'
)
server.ingestRemote({ ...manualEnvelope('PreCompact', 'working'), isReplay: true }, 'conn-a')
server.ingestRemote(manualEnvelope('PostCompact', 'done'), 'conn-a')
expect(server.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'new work',
agentType: 'claude'
})
expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
source: 'codex',
hookEventName: 'UserPromptSubmit'
})
})
it('keeps automatic compact hooks working without settling the turn', async () => {
const main = new AgentHookServer()
const forwarded: AgentHookRelayEnvelope[] = []
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-auto-compact-relay-'))
temporaryPaths.push(endpointDir)
const relay = new RelayAgentHookServer({
endpointDir,
forward: (envelope) => {
forwarded.push(envelope)
main.ingestRemote(envelope, 'conn-auto')
}
})
servers.push(main, relay)
await relay.start({ publishEndpoint: false })
const { port, token } = relay.getCoordinates()
await postHook(
port,
token,
claudeHook('UserPromptSubmit', PROMPT_ID_1, { prompt: 'continue the task' })
)
await postHook(port, token, claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'auto' }))
await postHook(port, token, claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'auto' }))
expect(forwarded.map((event) => event.hookEventName)).toEqual([
'UserPromptSubmit',
'PreCompact',
'PostCompact'
])
expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType: 'claude' })
expect(main._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
hookEventName: 'PostCompact',
compactTrigger: undefined
})
})
it('rejects unproven compact sources at the main relay boundary', () => {
const server = new AgentHookServer()
servers.push(server)
server.ingestRemote(
{
...manualEnvelope('PreCompact', 'working'),
source: 'kimi',
payload: { state: 'working', prompt: '', agentType: 'kimi' }
},
'conn-kimi'
)
server.ingestRemote(
{ ...manualEnvelope('PreCompact', 'working'), compactTrigger: undefined },
'conn-auto'
)
expect(server.getStatusSnapshot()).toEqual([])
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
})
it('hydrates a manual PreCompact identity and accepts only its exact local completion', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-compact-restore-'))
temporaryPaths.push(userDataPath)
const first = new AgentHookServer()
servers.push(first)
await first.start({ env: 'production', userDataPath })
const firstEnv = first.buildPtyEnv()
await postHook(
Number(firstEnv.ORCA_AGENT_HOOK_PORT),
firstEnv.ORCA_AGENT_HOOK_TOKEN,
claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
)
await postHook(
Number(firstEnv.ORCA_AGENT_HOOK_PORT),
firstEnv.ORCA_AGENT_HOOK_TOKEN,
claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
)
first.stop()
const restored = new AgentHookServer()
servers.push(restored)
await restored.start({ env: 'production', userDataPath })
const restoredEnv = restored.buildPtyEnv()
await postHook(
Number(restoredEnv.ORCA_AGENT_HOOK_PORT),
restoredEnv.ORCA_AGENT_HOOK_TOKEN,
claudeHook('PostCompact', PROMPT_ID_2, { trigger: 'manual' })
)
expect(restored.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
await postHook(
Number(restoredEnv.ORCA_AGENT_HOOK_PORT),
restoredEnv.ORCA_AGENT_HOOK_TOKEN,
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
)
expect(restored.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'work before compact',
agentType: 'claude'
})
})
})
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../shared/stable-pane-id'
import { AgentHookServer } from './server'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111')
function compactEvent(hookEventName: string, state: 'working' | 'done') {
return {
source: 'claude' as const,
paneKey: PANE_KEY,
hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
hookEventName,
providerPromptId:
hookEventName === 'UserPromptSubmit'
? '22222222-2222-4222-8222-222222222222'
: '33333333-3333-4333-8333-333333333333',
compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const),
providerSession: { key: 'session_id' as const, id: 'session-a' },
payload: { state, prompt: 'work before compact', agentType: 'claude' as const }
}
}
describe('manual compact status cleanup', () => {
it('retires authority with pane, tab, and server cleanup', () => {
const server = new AgentHookServer()
const begin = (): void => {
server.ingestRemote(compactEvent('UserPromptSubmit', 'working'), 'conn-a')
server.ingestRemote(compactEvent('PreCompact', 'working'), 'conn-a')
}
begin()
server.clearPaneState(PANE_KEY)
server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a')
expect(server.getStatusSnapshot()).toEqual([])
begin()
server.dropStatusEntriesByTabPrefix('manual-compact')
server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a')
expect(server.getStatusSnapshot()).toEqual([])
server.stop()
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
})
})
+74 -3
View File
@@ -22,11 +22,14 @@ import {
markCodexLeadTurnInterrupted,
MAX_PANE_KEY_LEN,
movePaneCacheState,
canAcceptClaudeCompactTransition,
normalizeClaudePromptId,
normalizeHookPayload,
parseFormEncodedBody,
readRequestBody,
reapRestoredClaudeSubagentsForDeadPane,
reconcileRemoteCodexState,
resolveCachedClaudeCompactOwnership,
resolveHookSource,
preparePendingGrokResultDiscovery,
seedClaudeSubagentRosterFromSnapshots,
@@ -41,7 +44,11 @@ import {
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../shared/claude-subagent-roster'
import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay'
import {
isAgentHookSource,
restoreShedStatusFields,
type AgentHookSource
} from '../../shared/agent-hook-relay'
import {
CLAUDE_STATUSLINE_PATHNAME,
parseClaudeStatusLineBody,
@@ -293,13 +300,23 @@ function sanitizeHydratedEntry(
if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) {
return null
}
const source = isAgentHookSource(record.source) ? record.source : undefined
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined
const compactTrigger =
source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto')
? record.compactTrigger
: undefined
return {
paneKey,
source,
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined,
hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined,
providerPromptId,
compactTrigger,
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
@@ -1236,7 +1253,8 @@ export class AgentHookServer {
if (!identity.inheritedFromActivePane) {
this.maybeTrackAgentPromptSent(effectivePayload, previous)
}
const enriched = this.attachStatusTiming(effectivePayload, now)
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload)
const enriched = this.attachStatusTiming(cachedPayload, now)
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
@@ -1834,6 +1852,9 @@ export class AgentHookServer {
hasExplicitPrompt?: boolean
promptInteractionKey?: string
hookEventName?: string
source?: unknown
providerPromptId?: unknown
compactTrigger?: unknown
toolUseId?: string
toolAgentId?: string
toolAgentType?: string
@@ -1895,6 +1916,14 @@ export class AgentHookServer {
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
? envelope.hookEventName.trim()
: undefined
const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined
const compactTrigger =
source === 'claude' &&
(envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto')
? envelope.compactTrigger
: undefined
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
hookEventName,
isReplay: envelope.isReplay === true
@@ -1930,11 +1959,50 @@ export class AgentHookServer {
return
}
// Why: restore a shed roster only when its digest and turn identity still match the cache.
const normalizedPayload = restoreShedStatusFields(
let normalizedPayload = restoreShedStatusFields(
validatedPayload,
envelope.shedFields,
this.state.lastStatusByPaneKey.get(paneKey)?.payload
)
const previousStatus = this.state.lastStatusByPaneKey.get(paneKey)
if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') {
if (
source !== 'claude' ||
compactTrigger === undefined ||
normalizedPayload.agentType !== source
) {
return
}
if (
hookEventName === 'PreCompact' &&
envelope.isReplay === true &&
(previousStatus?.hookEventName !== 'PreCompact' ||
previousStatus.compactTrigger !== compactTrigger ||
previousStatus.providerPromptId !== providerPromptId)
) {
return
}
if (
!canAcceptClaudeCompactTransition(previousStatus, {
source,
connectionId: trimmedConnectionId,
hookEventName,
providerPromptId,
compactTrigger,
providerSession
})
) {
return
}
}
if (
source === 'claude' &&
compactTrigger !== undefined &&
normalizedPayload.prompt.length === 0 &&
previousStatus?.payload.prompt
) {
normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt }
}
if (
envelope.providerSessionOnly === true &&
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
@@ -1954,6 +2022,7 @@ export class AgentHookServer {
})
const event: AgentHookEventPayload = {
paneKey,
source,
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
tabId,
worktreeId,
@@ -1961,6 +2030,8 @@ export class AgentHookServer {
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
promptInteractionKey,
hookEventName,
providerPromptId,
compactTrigger,
toolUseId,
toolAgentId,
toolAgentType,
@@ -40,6 +40,8 @@ const { SshRelaySession } = await import('./ssh-relay-session')
const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333'
const COMPACT_PROMPT_ID = '44444444-4444-4444-8444-444444444444'
const PREVIOUS_PROMPT_ID = '55555555-5555-4555-8555-555555555555'
type CapturedStatus = {
paneKey: string
@@ -295,6 +297,68 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
})
})
it('stamps SSH ownership and settles only the exact manual compact identity', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
serverBuildId: 'test-relay-build',
platform: 'linux-x64'
})
const events: CapturedStatus[] = []
captureAgentStatuses(events)
session = createSession('conn-compact')
await session.establish({} as SshConnection)
const compactEnvelope = (
hookEventName: 'UserPromptSubmit' | 'PreCompact' | 'PostCompact',
state: 'working' | 'done'
): AgentHookRelayEnvelope =>
makeEnvelope({
source: 'claude',
hookEventName,
providerPromptId:
hookEventName === 'UserPromptSubmit' ? PREVIOUS_PROMPT_ID : COMPACT_PROMPT_ID,
compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : 'manual',
providerSession: { key: 'session_id', id: 'claude-session' },
hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
payload: {
state,
prompt: 'work before compact',
agentType: 'claude'
}
})
relay.notifyAgentHook(compactEnvelope('UserPromptSubmit', 'working'))
relay.notifyAgentHook(compactEnvelope('PreCompact', 'working'))
await waitForStatusCount(events, 2)
relay.notifyAgentHook({
...compactEnvelope('PostCompact', 'done'),
providerPromptId: undefined
})
await new Promise((resolve) => setImmediate(resolve))
expect(events).toHaveLength(2)
relay.notifyAgentHook(compactEnvelope('PostCompact', 'done'))
await waitForStatusCount(events, 3)
expect(events.at(-1)).toMatchObject({
connectionId: 'conn-compact',
payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' }
})
expect(
agentHookServer._getStateForTests().lastStatusByPaneKey.values().next().value
).toMatchObject({
source: 'claude',
providerPromptId: COMPACT_PROMPT_ID,
compactTrigger: undefined,
connectionId: 'conn-compact'
})
relay.notifyAgentHook(compactEnvelope('PostCompact', 'done'))
await new Promise((resolve) => setImmediate(resolve))
expect(events).toHaveLength(3)
})
it('clears stamped status on reconnect loss but not final shutdown', async () => {
const initialRelay = createFakeRelay()
relay = createFakeRelay()
+6
View File
@@ -1342,6 +1342,9 @@ export class SshRelaySession {
hasExplicitPrompt?: unknown
promptInteractionKey?: unknown
hookEventName?: unknown
source?: unknown
providerPromptId?: unknown
compactTrigger?: unknown
toolUseId?: unknown
toolAgentId?: unknown
toolAgentType?: unknown
@@ -1371,6 +1374,9 @@ export class SshRelaySession {
: undefined,
hookEventName:
typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined,
source: envelope.source,
providerPromptId: envelope.providerPromptId,
compactTrigger: envelope.compactTrigger,
toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined,
toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined,
toolAgentType:
+10 -2
View File
@@ -20,6 +20,7 @@ import {
normalizeHookPayload,
preparePendingGrokResultDiscovery,
readRequestBody,
resolveCachedClaudeCompactOwnership,
resolveHookSource,
writeEndpointFile,
type AgentHookEventPayload,
@@ -275,7 +276,10 @@ export class RelayAgentHookServer {
res.end()
return
}
const event = normalizeHookPayload(this.state, source, body, this.env)
const event = normalizeHookPayload(this.state, source, body, this.env, {
allowUnanchoredPreCompact: true,
allowUnanchoredPostCompact: true
})
if (event) {
// TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result.
const env = this.bodyEnv(body)
@@ -313,6 +317,8 @@ export class RelayAgentHookServer {
hasExplicitPrompt: event.hasExplicitPrompt,
promptInteractionKey: event.promptInteractionKey,
hookEventName: event.hookEventName,
providerPromptId: event.providerPromptId,
compactTrigger: event.compactTrigger,
toolUseId: event.toolUseId,
toolAgentId: event.toolAgentId,
toolAgentType: event.toolAgentType,
@@ -336,9 +342,11 @@ export class RelayAgentHookServer {
if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) {
this.clearAssistantMessageRetry(event.paneKey)
}
const previous = this.state.lastStatusByPaneKey.get(event.paneKey)
const cachedEvent = resolveCachedClaudeCompactOwnership(previous, event)
// Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane.
this.state.lastStatusByPaneKey.delete(event.paneKey)
this.state.lastStatusByPaneKey.set(event.paneKey, event)
this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent)
this.lastEnvelopeMetaByPaneKey.delete(event.paneKey)
this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version })
while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) {
+150 -4
View File
@@ -32,6 +32,20 @@ import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
const CLAUDE_PROMPT_ID = '22222222-2222-4222-8222-222222222222'
const CLAUDE_PREVIOUS_PROMPT_ID = '33333333-3333-4333-8333-333333333333'
function normalizeAndAccept(
state: HookListenerState,
source: Parameters<typeof normalizeHookPayload>[1],
payload: Record<string, unknown>
): ReturnType<typeof normalizeHookPayload> {
const event = normalizeHookPayload(state, source, { paneKey: PANE_KEY, payload }, 'production')
if (event) {
state.lastStatusByPaneKey.set(PANE_KEY, event)
}
return event
}
type FakeIncomingMessage = EventEmitter & {
headers: IncomingHttpHeaders
@@ -1452,6 +1466,69 @@ describe('shared agent-hook-listener', () => {
expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' })
})
// Why: Kimi shares Claude-compatible compact/harness hooks; cover the same sticky-working
// guards so a Kimi-only regression cannot slip past the Claude-only tests (issue #11352).
it('ignores harness-injected UserPromptSubmit for Kimi', () => {
normalizeHookPayload(
state,
'kimi',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'UserPromptSubmit',
prompt: [{ type: 'text', text: 'list the files here' }]
}
},
'production'
)
const harness = normalizeHookPayload(
state,
'kimi',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'UserPromptSubmit',
prompt:
'This session is being continued from a previous conversation that ran out of context.'
}
},
'production'
)
expect(harness).toBeNull()
const tool = normalizeHookPayload(
state,
'kimi',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
tool_name: 'Bash',
tool_input: { command: 'ls' }
}
},
'production'
)
expect(tool).not.toBeNull()
expect(tool!.payload.state).toBe('working')
expect(tool!.payload.prompt).toBe('list the files here')
expect(tool!.payload.agentType).toBe('kimi')
})
it('ignores unproven Kimi compact lifecycle events', () => {
const pre = normalizeAndAccept(state, 'kimi', {
hook_event_name: 'PreCompact',
trigger: 'manual'
})
const post = normalizeAndAccept(state, 'kimi', {
hook_event_name: 'PostCompact',
trigger: 'manual'
})
expect(pre).toBeNull()
expect(post).toBeNull()
expect(state.lastStatusByPaneKey.has(PANE_KEY)).toBe(false)
})
it('normalizes MiMo Code OpenCode-compatible lifecycle events as mimo-code status', () => {
const message = normalizeHookPayload(
state,
@@ -1563,15 +1640,13 @@ describe('shared agent-hook-listener', () => {
expect(event).toBeNull()
})
it('keeps the cached prompt when a harness-injected turn fires UserPromptSubmit', () => {
it('resumes work for task notifications without replacing the cached prompt', () => {
normalizeHookPayload(
state,
'claude',
{ paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'fix login' } },
'production'
)
// Why: the harness injects background task notifications as user turns;
// they must not replace the user's real prompt in status labels.
const event = normalizeHookPayload(
state,
'claude',
@@ -1590,7 +1665,7 @@ describe('shared agent-hook-listener', () => {
expect(event!.hasExplicitPrompt).toBe(false)
})
it('resolves an empty prompt for a harness-injected turn with nothing cached', () => {
it('emits a harness-injected UserPromptSubmit with an empty uncached prompt', () => {
const event = normalizeHookPayload(
state,
'claude',
@@ -1604,10 +1679,81 @@ describe('shared agent-hook-listener', () => {
'production'
)
expect(event).not.toBeNull()
expect(event!.payload.state).toBe('working')
expect(event!.payload.prompt).toBe('')
expect(event!.hasExplicitPrompt).toBe(false)
})
it('does not leave working after a compact-summary UserPromptSubmit (issue #11352)', () => {
// Live repro: after /compact Claude injects "This session is being continued…" with no Stop.
const event = normalizeHookPayload(
state,
'claude',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'UserPromptSubmit',
prompt:
'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.'
}
},
'production'
)
expect(event).toBeNull()
})
it('maps an identity-matched Claude manual compact lifecycle', () => {
normalizeAndAccept(state, 'claude', {
hook_event_name: 'UserPromptSubmit',
prompt: 'work before compact',
prompt_id: CLAUDE_PREVIOUS_PROMPT_ID,
session_id: 'session-a'
})
const pre = normalizeAndAccept(state, 'claude', {
hook_event_name: 'PreCompact',
trigger: 'manual',
prompt_id: CLAUDE_PROMPT_ID,
session_id: 'session-a'
})
expect(pre).not.toBeNull()
expect(pre!.payload.state).toBe('working')
expect(pre!.payload.agentType).toBe('claude')
const post = normalizeAndAccept(state, 'claude', {
hook_event_name: 'PostCompact',
trigger: 'manual',
prompt_id: CLAUDE_PROMPT_ID,
session_id: 'session-a'
})
expect(post).not.toBeNull()
expect(post!.payload.state).toBe('done')
expect(post!.payload.agentType).toBe('claude')
})
it('keeps the preceding user prompt on the completed compact row', () => {
normalizeAndAccept(state, 'claude', {
hook_event_name: 'UserPromptSubmit',
prompt: 'work before compact',
prompt_id: CLAUDE_PREVIOUS_PROMPT_ID,
session_id: 'session-a'
})
normalizeAndAccept(state, 'claude', {
hook_event_name: 'PreCompact',
trigger: 'manual',
prompt_id: CLAUDE_PROMPT_ID,
session_id: 'session-a'
})
const post = normalizeAndAccept(state, 'claude', {
hook_event_name: 'PostCompact',
trigger: 'manual',
prompt_id: CLAUDE_PROMPT_ID,
session_id: 'session-a'
})
expect(post).not.toBeNull()
expect(post!.payload.state).toBe('done')
expect(post!.payload.prompt).toBe('work before compact')
})
it('treats a custom-element paste as an explicit user turn, not machinery', () => {
normalizeHookPayload(
state,
+178 -9
View File
@@ -55,11 +55,15 @@ import {
import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types'
import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay'
import {
agentProviderSessionsEqual,
extractAgentProviderSession,
type AgentProviderSessionMetadata
} from './agent-session-resume'
import { parsePaneKey } from './stable-pane-id'
import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns'
import {
isCompactContinuationUserTurnText,
isKnownHarnessInjectedUserTurnText
} from './harness-injected-user-turns'
import {
buildGrokChatHistoryPathCandidates,
findGrokChatHistoryBySessionId,
@@ -102,6 +106,15 @@ function capOpenCodeHookText(text: string): string {
/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */
export const MAX_PANE_KEY_LEN = 200
const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
export function normalizeClaudePromptId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = value.trim().toLowerCase()
return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined
}
/** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */
export type HookListenerState = {
@@ -298,6 +311,8 @@ export function warnOnHookEnvOrVersionMismatch(
export type AgentHookEventPayload = {
paneKey: string
/** Authenticated hook route that produced this event. */
source?: AgentHookSource
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
tabId?: string
@@ -310,6 +325,10 @@ export type AgentHookEventPayload = {
promptInteractionKey?: string
/** Raw agent hook event name, used by main-process transition guards. */
hookEventName?: string
/** Claude's provider-owned user-prompt UUID. */
providerPromptId?: string
/** Active Claude compact generation, keyed by provider prompt identity. */
compactTrigger?: 'manual' | 'auto'
/** Claude tool-use identifier when the hook source exposes one. */
toolUseId?: string
/** Claude agent/subagent identifier when the hook source exposes one. */
@@ -327,6 +346,92 @@ export type AgentHookEventPayload = {
payload: ParsedAgentStatusPayload
}
type ClaudeCompactIdentity = Pick<
AgentHookEventPayload,
| 'source'
| 'connectionId'
| 'hookEventName'
| 'providerPromptId'
| 'compactTrigger'
| 'providerSession'
>
export function canAcceptClaudeCompactTransition(
previous: AgentHookEventPayload | undefined,
incoming: ClaudeCompactIdentity,
options: { allowUnanchoredPreCompact?: boolean; allowUnanchoredPostCompact?: boolean } = {}
): boolean {
if (
incoming.source !== 'claude' ||
incoming.compactTrigger === undefined ||
incoming.providerPromptId === undefined ||
(incoming.hookEventName !== 'PreCompact' && incoming.hookEventName !== 'PostCompact')
) {
return false
}
if (incoming.hookEventName === 'PreCompact' && options.allowUnanchoredPreCompact) {
return true
}
if (incoming.hookEventName === 'PostCompact' && options.allowUnanchoredPostCompact) {
return true
}
if (
previous?.source !== 'claude' ||
previous.payload.agentType !== 'claude' ||
previous.connectionId !== incoming.connectionId ||
!agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession)
) {
return false
}
if (incoming.hookEventName === 'PostCompact') {
return (
previous.compactTrigger === incoming.compactTrigger &&
previous.providerPromptId === incoming.providerPromptId
)
}
return incoming.compactTrigger === 'manual'
? previous.providerPromptId !== undefined
: previous.providerPromptId === incoming.providerPromptId
}
export function resolveCachedClaudeCompactOwnership(
previous: AgentHookEventPayload | undefined,
incoming: AgentHookEventPayload
): AgentHookEventPayload {
const sameClaudeOwner =
previous?.source === 'claude' &&
previous.payload.agentType === 'claude' &&
incoming.source === 'claude' &&
incoming.payload.agentType === 'claude' &&
incoming.connectionId === previous.connectionId &&
agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession)
? previous
: undefined
if (incoming.hookEventName === 'PreCompact' && incoming.compactTrigger) {
return sameClaudeOwner?.payload.prompt && incoming.payload.prompt.length === 0
? { ...incoming, payload: { ...incoming.payload, prompt: sameClaudeOwner.payload.prompt } }
: incoming
}
if (incoming.hookEventName === 'PostCompact') {
return incoming.compactTrigger ? { ...incoming, compactTrigger: undefined } : incoming
}
const ownsCompact =
sameClaudeOwner?.compactTrigger !== undefined &&
sameClaudeOwner.providerPromptId !== undefined &&
incoming.providerPromptId === sameClaudeOwner.providerPromptId
if (ownsCompact) {
return {
...incoming,
compactTrigger: sameClaudeOwner.compactTrigger,
payload:
incoming.payload.prompt.length === 0 && sameClaudeOwner.payload.prompt
? { ...incoming.payload, prompt: sameClaudeOwner.payload.prompt }
: incoming.payload
}
}
return incoming.compactTrigger ? { ...incoming, compactTrigger: undefined } : incoming
}
// ─── Body parsing ───────────────────────────────────────────────────
export function parseFormEncodedBody(body: string): Record<string, string> {
@@ -504,6 +609,14 @@ function stripGrokUserQueryWrapper(promptText: string): string {
return text.trim()
}
// Why: the post-compact continuation prompt has no matching Stop and would resurrect working.
function shouldIgnoreCompactContinuationUserPromptSubmit(
eventName: unknown,
promptText: string
): boolean {
return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText)
}
function resolvePrompt(
state: HookListenerState,
paneKey: string,
@@ -2640,19 +2753,27 @@ function normalizeClaudeEvent(
const sessionCronInventoryPresent = Array.isArray(sessionCrons)
const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0
if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) {
return null
}
// Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer.
// Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent.
const isAskUserQuestion =
eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name'))
// Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy;
// PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352).
const reportedStateName =
eventName === 'UserPromptSubmit' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure' ||
eventName === 'PreCompact' ||
(eventName === 'PostCompact' && hookPayload.trigger === 'auto') ||
(eventName === 'PreToolUse' && !isAskUserQuestion)
? 'working'
: eventName === 'PermissionRequest' || isAskUserQuestion
? 'waiting'
: isTurnBoundary
: isTurnBoundary || (eventName === 'PostCompact' && hookPayload.trigger === 'manual')
? 'done'
: null
@@ -2863,6 +2984,10 @@ function normalizeKimiEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) {
return null
}
const toolName = readString(hookPayload, 'tool_name')
const isUserInputTool = isKimiUserInputTool(toolName)
@@ -3938,7 +4063,8 @@ export function normalizeHookPayload(
state: HookListenerState,
source: AgentHookSource,
body: unknown,
expectedEnv: string
expectedEnv: string,
options: { allowUnanchoredPreCompact?: boolean; allowUnanchoredPostCompact?: boolean } = {}
): AgentHookEventPayload | null {
if (typeof body !== 'object' || body === null) {
return null
@@ -3987,6 +4113,52 @@ export function normalizeHookPayload(
readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ??
hookPayloadRecord.hook_event_name ??
hookPayloadRecord.hookEventName
// Why: Codex child hooks expose the child's session_id on the parent's pane.
const providerSession =
source === 'codex' && readString(hookPayloadRecord, 'agent_id')
? null
: extractAgentProviderSession(source, hookPayloadRecord)
const providerPromptId =
source === 'claude' ? normalizeClaudePromptId(hookPayloadRecord.prompt_id) : undefined
const compactTrigger =
source === 'claude' &&
(eventName === 'PreCompact' || eventName === 'PostCompact') &&
(hookPayloadRecord.trigger === 'manual' || hookPayloadRecord.trigger === 'auto')
? hookPayloadRecord.trigger
: undefined
const isCompactEvent = eventName === 'PreCompact' || eventName === 'PostCompact'
if (isCompactEvent && compactTrigger === undefined) {
return null
}
const previousStatus = state.lastStatusByPaneKey.get(paneKey)
if (
compactTrigger !== undefined &&
!canAcceptClaudeCompactTransition(
previousStatus,
{
source,
connectionId: null,
hookEventName: typeof eventName === 'string' ? eventName : undefined,
providerPromptId,
compactTrigger,
providerSession: providerSession ?? undefined
},
{
allowUnanchoredPreCompact: options.allowUnanchoredPreCompact,
allowUnanchoredPostCompact: options.allowUnanchoredPostCompact
}
)
) {
return null
}
if (
eventName === 'PostCompact' &&
compactTrigger !== undefined &&
previousStatus?.payload.prompt &&
!state.lastPromptByPaneKey.has(paneKey)
) {
state.lastPromptByPaneKey.set(paneKey, previousStatus.payload.prompt)
}
const extractedPrompt = extractPromptText(hookPayload as Record<string, unknown>)
const promptText = extractedPrompt.text
let resolvedPromptText = promptText
@@ -4108,12 +4280,6 @@ export function normalizeHookPayload(
}
// Why: connectionId is null here; ingestRemote stamps it from mux identity on receive. See docs/design/agent-status-over-ssh.md §5.
// Why: Codex child hooks expose the child's session_id on the parent's pane;
// treating it as the root resume id would replace the terminal's real session.
const providerSession =
source === 'codex' && readString(hookPayloadRecord, 'agent_id')
? null
: extractAgentProviderSession(source, hookPayloadRecord)
const providerSessionOnly =
source === 'pi' && eventName === 'session_start' && providerSession !== null
// Why: Pi session_start carries resume identity while idle; providerSessionOnly makes receivers discard the placeholder row.
@@ -4125,6 +4291,7 @@ export function normalizeHookPayload(
return transportPayload
? {
paneKey,
source,
launchToken,
tabId,
worktreeId,
@@ -4143,6 +4310,8 @@ export function normalizeHookPayload(
),
promptInteractionKey,
hookEventName: typeof eventName === 'string' ? eventName : undefined,
providerPromptId,
compactTrigger,
toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']),
toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']),
toolAgentType: readString(hookPayloadRecord, 'agent_type'),
+11
View File
@@ -6,6 +6,7 @@ import {
AGENT_HOOK_SHED_FIELDS_KEY,
ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV,
createShedSubagentsField,
isAgentHookSource,
isRemoteAgentHooksEnabled,
restoreShedStatusFields,
type AgentHookRelayEnvelope
@@ -22,6 +23,8 @@ describe('agent-hook-relay wire shape', () => {
connectionId: null,
env: 'production',
version: '1',
providerPromptId: '11111111-1111-4111-8111-111111111111',
compactTrigger: 'manual',
payload: {
state: 'working',
prompt: 'roundtrip',
@@ -40,6 +43,14 @@ describe('agent-hook-relay wire shape', () => {
expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay')
expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins')
})
it('validates hook sources crossing persisted and relay trust boundaries', () => {
expect(isAgentHookSource('claude')).toBe(true)
expect(isAgentHookSource('kimi')).toBe(true)
expect(isAgentHookSource('claude\0codex')).toBe(false)
expect(isAgentHookSource('unknown')).toBe(false)
expect(isAgentHookSource({ source: 'claude' })).toBe(false)
})
})
describe('isRemoteAgentHooksEnabled', () => {
+31 -18
View File
@@ -34,24 +34,33 @@ import type { AgentHookTarget } from './agent-hook-types'
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
// without dragging Electron in (the shared listener module is the only place
// that consumes it from the relay side).
export type AgentHookSource =
| 'claude'
| 'codex'
| 'gemini'
| 'antigravity'
| 'amp'
| 'opencode'
| 'mimo-code'
| 'cursor'
| 'pi'
| 'omp'
| 'droid'
| 'command-code'
| 'grok'
| 'copilot'
| 'hermes'
| 'devin'
| 'kimi'
const AGENT_HOOK_SOURCES = [
'claude',
'codex',
'gemini',
'antigravity',
'amp',
'opencode',
'mimo-code',
'cursor',
'pi',
'omp',
'droid',
'command-code',
'grok',
'copilot',
'hermes',
'devin',
'kimi'
] as const
export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
const AGENT_HOOK_SOURCE_SET: ReadonlySet<string> = new Set(AGENT_HOOK_SOURCES)
export function isAgentHookSource(value: unknown): value is AgentHookSource {
return typeof value === 'string' && AGENT_HOOK_SOURCE_SET.has(value)
}
/** Env marker used by the remote relay. It is a transport/location marker, not
* a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */
@@ -75,6 +84,10 @@ export type AgentHookRelayEnvelope = {
promptInteractionKey?: string
/** Hook discriminator preserved for main-process transition rules. */
hookEventName?: string
/** Claude's provider-owned user-prompt UUID. */
providerPromptId?: string
/** Active Claude compact generation, keyed by provider prompt identity. */
compactTrigger?: 'manual' | 'auto'
/** Claude tool execution id, when the source hook provides one. */
toolUseId?: string
/** Claude subagent identity, when the source hook provides one. */
+14 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns'
import {
isCompactContinuationUserTurnText,
isKnownHarnessInjectedUserTurnText
} from './harness-injected-user-turns'
describe('isKnownHarnessInjectedUserTurnText', () => {
it('matches every known harness tag, including attribute-carrying forms', () => {
@@ -59,6 +62,16 @@ describe('isKnownHarnessInjectedUserTurnText', () => {
).toBe(true)
})
it('distinguishes compact continuation from other harness turns', () => {
expect(
isCompactContinuationUserTurnText(
'This session is being continued from a previous conversation. Summary follows.'
)
).toBe(true)
expect(isCompactContinuationUserTurnText('<task-notification>done')).toBe(false)
expect(isCompactContinuationUserTurnText('<command-name>/review</command-name>')).toBe(false)
})
it('is case-insensitive and ignores surrounding whitespace', () => {
expect(isKnownHarnessInjectedUserTurnText(' <TASK-NOTIFICATION> done')).toBe(true)
expect(isKnownHarnessInjectedUserTurnText('\n<System-Reminder> hi')).toBe(true)
+22 -12
View File
@@ -39,6 +39,7 @@ const KNOWN_HARNESS_TAG_NAMES = new Set([
// Injected turns identified by a leading string rather than a known tag name:
// the harness only emits <channel> in its attributed `<channel source=…>` form
// (a bare <channel> is a real RSS/XML paste), plus prose deliveries and notices.
const COMPACT_CONTINUATION_PREFIX = 'this session is being continued from a previous conversation'
const HARNESS_INJECTED_TURN_PREFIXES = [
'<channel source=',
'[request interrupted',
@@ -46,7 +47,7 @@ const HARNESS_INJECTED_TURN_PREFIXES = [
'another claude session sent a message',
'no response requested.',
'caveat: the messages below were generated by the user while running local commands',
'this session is being continued from a previous conversation'
COMPACT_CONTINUATION_PREFIX
]
// Why: classification only inspects leading tags/prefixes. Cap the toLowerCase
@@ -57,17 +58,8 @@ const HARNESS_CLASSIFY_LEADING_WS_LIMIT = 64
/** True only for observed harness shapes. Match on trimmed, lowercased text.
* Unknown kebab tags stay user turns — only tags we have observed count. */
export function isKnownHarnessInjectedUserTurnText(text: string): boolean {
let start = 0
const wsScanEnd = Math.min(text.length, HARNESS_CLASSIFY_LEADING_WS_LIMIT)
while (start < wsScanEnd && isAsciiWhitespace(text.charCodeAt(start))) {
start += 1
}
if (start >= text.length) {
return false
}
const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT)
const normalized = text.slice(start, headEnd).toLowerCase()
if (normalized.length === 0) {
const normalized = normalizedHarnessTurnHead(text)
if (!normalized) {
return false
}
const tagName = LEADING_TAG_NAME.exec(normalized)?.[1]
@@ -77,6 +69,24 @@ export function isKnownHarnessInjectedUserTurnText(text: string): boolean {
return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}
/** True only for the observed post-compaction continuation prompt. */
export function isCompactContinuationUserTurnText(text: string): boolean {
return normalizedHarnessTurnHead(text).startsWith(COMPACT_CONTINUATION_PREFIX)
}
function normalizedHarnessTurnHead(text: string): string {
let start = 0
const wsScanEnd = Math.min(text.length, HARNESS_CLASSIFY_LEADING_WS_LIMIT)
while (start < wsScanEnd && isAsciiWhitespace(text.charCodeAt(start))) {
start += 1
}
if (start >= text.length) {
return ''
}
const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT)
return text.slice(start, headEnd).toLowerCase()
}
function isAsciiWhitespace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13 || code === 12
}
@@ -0,0 +1,235 @@
import { describe, expect, it } from 'vitest'
import {
createHookListenerState,
normalizeHookPayload,
resolveCachedClaudeCompactOwnership,
type AgentHookEventPayload,
type HookListenerState
} from './agent-hook-listener'
import type { AgentHookSource } from './agent-hook-relay'
import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('manual-compact-prompt', LEAF_ID)
const PROMPT_ID_1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'
const PROMPT_ID_2 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'
function accept(
state: HookListenerState,
source: AgentHookSource,
payload: Record<string, unknown>,
paneKey = PANE_KEY
): AgentHookEventPayload | null {
const event = normalizeHookPayload(state, source, { paneKey, payload }, 'production')
if (event) {
state.lastStatusByPaneKey.set(
paneKey,
resolveCachedClaudeCompactOwnership(state.lastStatusByPaneKey.get(paneKey), event)
)
}
return event
}
function beginManualCompact(
state: HookListenerState,
promptId: string,
sessionId: string | undefined = 'session-a'
): AgentHookEventPayload | null {
const identity = {
prompt_id: promptId,
...(sessionId ? { session_id: sessionId } : {})
}
accept(state, 'claude', {
hook_event_name: 'UserPromptSubmit',
prompt: '/compact',
...identity
})
return accept(state, 'claude', {
hook_event_name: 'PreCompact',
trigger: 'manual',
...identity
})
}
function finishManualCompact(
state: HookListenerState,
promptId: string | undefined,
sessionId: string | null = 'session-a'
): AgentHookEventPayload | null {
return accept(state, 'claude', {
hook_event_name: 'PostCompact',
trigger: 'manual',
...(promptId ? { prompt_id: promptId } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
}
describe('manual compact prompt identity', () => {
it('settles only the matching Claude manual compact', () => {
const state = createHookListenerState()
const pre = beginManualCompact(state, PROMPT_ID_1)
const post = finishManualCompact(state, PROMPT_ID_1)
expect(pre).toMatchObject({
source: 'claude',
providerPromptId: PROMPT_ID_1,
compactTrigger: 'manual',
hookEventName: 'PreCompact',
payload: { state: 'working', prompt: '/compact', agentType: 'claude' }
})
expect(post).toMatchObject({
source: 'claude',
providerPromptId: PROMPT_ID_1,
compactTrigger: 'manual',
hookEventName: 'PostCompact',
payload: { state: 'done', prompt: '/compact', agentType: 'claude' }
})
})
it('rejects stale, duplicate, and session-mismatched completion', () => {
const state = createHookListenerState()
beginManualCompact(state, PROMPT_ID_1)
expect(finishManualCompact(state, PROMPT_ID_1, 'session-b')).toBeNull()
expect(finishManualCompact(state, PROMPT_ID_1)).not.toBeNull()
expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
beginManualCompact(state, PROMPT_ID_2)
expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
providerPromptId: PROMPT_ID_2,
hookEventName: 'PreCompact',
payload: { state: 'working' }
})
})
it('does not let stale compact events replace intervening provider work', () => {
const state = createHookListenerState()
beginManualCompact(state, PROMPT_ID_1)
expect(finishManualCompact(state, PROMPT_ID_1)).not.toBeNull()
accept(state, 'codex', {
hook_event_name: 'UserPromptSubmit',
prompt: 'new Codex work',
session_id: 'codex-session'
})
expect(
accept(state, 'claude', {
hook_event_name: 'PreCompact',
trigger: 'manual',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
).toBeNull()
expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
source: 'codex',
payload: { state: 'working', prompt: 'new Codex work', agentType: 'codex' }
})
})
it('fails closed without exact prompt identity and session presence', () => {
const state = createHookListenerState()
expect(beginManualCompact(state, 'not-a-uuid')).toBeNull()
expect(beginManualCompact(state, PROMPT_ID_1)).not.toBeNull()
expect(finishManualCompact(state, undefined)).toBeNull()
expect(finishManualCompact(state, PROMPT_ID_1, null)).toBeNull()
expect(state.lastStatusByPaneKey.get(PANE_KEY)?.hookEventName).toBe('PreCompact')
})
it('keeps automatic compact working and ignores unproven Kimi events', () => {
const state = createHookListenerState()
const current = accept(state, 'claude', {
hook_event_name: 'UserPromptSubmit',
prompt: 'continue the task',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
const automaticPre = accept(state, 'claude', {
hook_event_name: 'PreCompact',
trigger: 'auto',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
const automaticPost = accept(state, 'claude', {
hook_event_name: 'PostCompact',
trigger: 'auto',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
const kimiPre = accept(state, 'kimi', {
hook_event_name: 'PreCompact',
trigger: 'manual',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
const kimiPost = accept(state, 'kimi', {
hook_event_name: 'PostCompact',
trigger: 'manual',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
expect(automaticPre).toMatchObject({
compactTrigger: 'auto',
hookEventName: 'PreCompact',
payload: { state: 'working' }
})
expect(automaticPost).toMatchObject({
compactTrigger: 'auto',
hookEventName: 'PostCompact',
payload: { state: 'working' }
})
expect(kimiPre).toBeNull()
expect(kimiPost).toBeNull()
expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
hookEventName: 'PostCompact',
compactTrigger: undefined,
payload: { state: 'working', prompt: current?.payload.prompt }
})
})
it('does not let stale manual completion retire a newer automatic compact', () => {
const state = createHookListenerState()
beginManualCompact(state, PROMPT_ID_1)
expect(
accept(state, 'claude', {
hook_event_name: 'PreCompact',
trigger: 'auto',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
})
).toMatchObject({ compactTrigger: 'auto', payload: { state: 'working' } })
expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull()
expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
compactTrigger: 'auto',
hookEventName: 'PreCompact',
payload: { state: 'working' }
})
})
it('does not allocate ownership for unaccepted untrusted pane keys', () => {
const state = createHookListenerState()
for (let index = 0; index < 4_097; index += 1) {
normalizeHookPayload(
state,
'claude',
{
paneKey: makePaneKey(`untrusted-${index}`, LEAF_ID),
payload: {
hook_event_name: 'PreCompact',
trigger: 'manual',
prompt_id: PROMPT_ID_1,
session_id: 'session-a'
}
},
'production'
)
}
expect(state.lastStatusByPaneKey.size).toBe(0)
expect(state.lastPromptByPaneKey.size).toBe(0)
})
})