2032 lines
75 KiB
TypeScript
2032 lines
75 KiB
TypeScript
/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */
|
|
// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron. See docs/design/agent-status-over-ssh.md §5.
|
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
|
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
import { track } from '../telemetry/client'
|
|
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
|
import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events'
|
|
import { ORCA_HOOK_PROTOCOL_VERSION } from '../../shared/agent-hook-types'
|
|
import {
|
|
clearAllListenerCaches,
|
|
clearPaneCacheState,
|
|
clearClaudeAnsweredQuestionWait,
|
|
createHookListenerState,
|
|
getEndpointFileName,
|
|
hasPendingAgentResultText,
|
|
HOOK_REQUEST_SLOWLORIS_MS,
|
|
markClaudeLeadTurnInterrupted,
|
|
markCodexLeadTurnInterrupted,
|
|
MAX_PANE_KEY_LEN,
|
|
movePaneCacheState,
|
|
normalizeHookPayload,
|
|
parseFormEncodedBody,
|
|
readRequestBody,
|
|
reconcileRemoteCodexState,
|
|
resolveHookSource,
|
|
preparePendingGrokResultDiscovery,
|
|
seedClaudeSubagentRosterFromSnapshots,
|
|
seedCodexStateFromSnapshot,
|
|
warnOnHookEnvOrVersionMismatch,
|
|
writeEndpointFile,
|
|
type AgentHookEventPayload,
|
|
type HookListenerState
|
|
} from '../../shared/agent-hook-listener'
|
|
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
|
import {
|
|
CLAUDE_STATUSLINE_PATHNAME,
|
|
parseClaudeStatusLineBody,
|
|
type ClaudeStatusLineRateLimits
|
|
} from '../../shared/claude-statusline-rate-limits'
|
|
import {
|
|
AGENT_STATUS_STALE_AFTER_MS,
|
|
type AgentStatusClearIpcPayload,
|
|
type AgentStatusIpcPayload,
|
|
type AgentType,
|
|
type AgentStatusState,
|
|
type ParsedAgentStatusPayload,
|
|
normalizeAgentStatusPayload
|
|
} from '../../shared/agent-status-types'
|
|
import {
|
|
resolveAgentStatusIdentity,
|
|
shouldSuppressInheritedTerminalStatus
|
|
} from '../../shared/agent-status-identity'
|
|
import {
|
|
isAgentInterruptInputIntent,
|
|
type AgentInterruptInferenceRequest
|
|
} from '../../shared/agent-interrupt-intent'
|
|
import {
|
|
isAskUserQuestionTool,
|
|
type AgentQuestionAnsweredInferenceRequest
|
|
} from '../../shared/agent-question-answered-intent'
|
|
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
|
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
|
|
import {
|
|
getAgentResumeArgv,
|
|
normalizeAgentProviderSession,
|
|
type AgentProviderSessionMetadata
|
|
} from '../../shared/agent-session-resume'
|
|
import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary'
|
|
|
|
export type { AgentHookSource }
|
|
|
|
// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears).
|
|
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
|
receivedAt: number
|
|
stateStartedAt: number
|
|
}
|
|
|
|
export type AgentHookStatusChangeEntry = {
|
|
state: AgentStatusState
|
|
receivedAt: number
|
|
observedInCurrentRuntime: boolean
|
|
}
|
|
|
|
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
|
|
type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
|
|
type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void
|
|
type PaneKeyAliasEntry = {
|
|
stablePaneKey: string
|
|
ptyId: string | null
|
|
updatedAt: number
|
|
authorityVerified: boolean
|
|
}
|
|
|
|
// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
|
|
const LAST_STATUS_FILE_NAME = 'last-status.json'
|
|
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
|
|
const ASSISTANT_MESSAGE_RETRY_MS = 50
|
|
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
|
|
|
|
// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt).
|
|
const LAST_STATUS_FILE_VERSION = 2
|
|
|
|
// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush.
|
|
const STATUS_PERSIST_DEBOUNCE_MS = 250
|
|
const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure'])
|
|
const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
|
|
|
|
// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate.
|
|
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session.
|
|
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
|
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
|
|
export const PANE_KEY_ALIASES_MAX = 1024
|
|
|
|
type LastStatusFile = {
|
|
version: number
|
|
entries: Record<string, EnrichedAgentHookEventPayload>
|
|
}
|
|
|
|
type AgentPromptSentDedupeEntry = {
|
|
agentKind: AgentKind
|
|
promptHash: string
|
|
promptInteractionKey?: string
|
|
}
|
|
|
|
function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind {
|
|
const normalized = agentType?.trim().toLowerCase()
|
|
if (!normalized || normalized === 'unknown') {
|
|
return 'other'
|
|
}
|
|
if (normalized === 'claude') {
|
|
return 'claude-code'
|
|
}
|
|
return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind)
|
|
? (normalized as AgentKind)
|
|
: 'other'
|
|
}
|
|
|
|
function equivalentInterruptAgentType(
|
|
actual: AgentType | undefined,
|
|
baseline: AgentType | undefined
|
|
): boolean {
|
|
const normalizedActual = actual === 'unknown' ? undefined : actual
|
|
const normalizedBaseline = baseline === 'unknown' ? undefined : baseline
|
|
return normalizedActual === normalizedBaseline
|
|
}
|
|
|
|
// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed.
|
|
export function isValidPaneKey(value: unknown): value is string {
|
|
return (
|
|
typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null
|
|
)
|
|
}
|
|
|
|
function dropHydratedIdleClaudeSubagents(
|
|
payload: ParsedAgentStatusPayload
|
|
): ParsedAgentStatusPayload {
|
|
if (
|
|
payload.agentType !== 'claude' ||
|
|
!payload.subagents?.some((subagent) => subagent.state === 'idle')
|
|
) {
|
|
return payload
|
|
}
|
|
const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle')
|
|
// Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart.
|
|
return {
|
|
...payload,
|
|
subagents: activeSubagents.length > 0 ? activeSubagents : undefined
|
|
}
|
|
}
|
|
|
|
// Why: the sole gate for keeping a providerSessionOnly row; shared so hydrate and relay-ingest can't drift.
|
|
function isValidPiProviderSessionOnly(
|
|
providerSession: AgentProviderSessionMetadata | undefined,
|
|
agentType: AgentType | undefined
|
|
): boolean {
|
|
return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession))
|
|
}
|
|
|
|
function sanitizeHydratedEntry(
|
|
paneKey: string,
|
|
rawEntry: unknown
|
|
): EnrichedAgentHookEventPayload | null {
|
|
const parsedPaneKey = parsePaneKey(paneKey)
|
|
if (!parsedPaneKey) {
|
|
return null
|
|
}
|
|
if (typeof rawEntry !== 'object' || rawEntry === null) {
|
|
return null
|
|
}
|
|
const record = rawEntry as Record<string, unknown>
|
|
if (record.paneKey !== paneKey) {
|
|
return null
|
|
}
|
|
const tabId = record.tabId
|
|
if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) {
|
|
return null
|
|
}
|
|
// Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row.
|
|
if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) {
|
|
return null
|
|
}
|
|
const worktreeId = record.worktreeId
|
|
if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) {
|
|
return null
|
|
}
|
|
const receivedAt = record.receivedAt
|
|
if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) {
|
|
return null
|
|
}
|
|
const stateStartedAt = record.stateStartedAt
|
|
if (
|
|
typeof stateStartedAt !== 'number' ||
|
|
!Number.isFinite(stateStartedAt) ||
|
|
stateStartedAt <= 0
|
|
) {
|
|
return null
|
|
}
|
|
// Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest.
|
|
const connectionIdRaw = record.connectionId
|
|
let connectionId: string | null
|
|
if (connectionIdRaw === null || connectionIdRaw === undefined) {
|
|
connectionId = null
|
|
} else if (typeof connectionIdRaw === 'string') {
|
|
connectionId = connectionIdRaw
|
|
} else {
|
|
return null
|
|
}
|
|
const payload = normalizeAgentStatusPayload(record.payload)
|
|
if (!payload) {
|
|
return null
|
|
}
|
|
const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined
|
|
const providerSessionOnly = record.providerSessionOnly === true
|
|
if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) {
|
|
return null
|
|
}
|
|
return {
|
|
paneKey,
|
|
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
|
|
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,
|
|
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
|
|
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
|
|
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
|
|
providerSession,
|
|
providerSessionOnly: providerSessionOnly ? true : undefined,
|
|
payload,
|
|
receivedAt,
|
|
stateStartedAt
|
|
}
|
|
}
|
|
|
|
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
|
|
return {
|
|
paneKey: entry.paneKey,
|
|
...(entry.launchToken ? { launchToken: entry.launchToken } : {}),
|
|
tabId: entry.tabId,
|
|
worktreeId: entry.worktreeId,
|
|
connectionId: entry.connectionId,
|
|
receivedAt: entry.receivedAt,
|
|
stateStartedAt: entry.stateStartedAt,
|
|
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
|
|
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
|
|
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
|
|
...entry.payload
|
|
}
|
|
}
|
|
|
|
// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph.
|
|
function equivalentParsedAgentStatusPayload(
|
|
a: ParsedAgentStatusPayload,
|
|
b: ParsedAgentStatusPayload
|
|
): boolean {
|
|
return (
|
|
a.state === b.state &&
|
|
a.prompt === b.prompt &&
|
|
a.agentType === b.agentType &&
|
|
a.toolName === b.toolName &&
|
|
a.toolInput === b.toolInput &&
|
|
a.interactivePrompt === b.interactivePrompt &&
|
|
a.lastAssistantMessage === b.lastAssistantMessage &&
|
|
a.interrupted === b.interrupted
|
|
)
|
|
}
|
|
|
|
function trackEmptyPaneKeyHook(body: unknown): void {
|
|
if (typeof body !== 'object' || body === null) {
|
|
return
|
|
}
|
|
const paneKey = (body as Record<string, unknown>).paneKey
|
|
if (typeof paneKey === 'string' && paneKey.trim().length > 0) {
|
|
return
|
|
}
|
|
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
|
}
|
|
|
|
function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean {
|
|
if (next.payload.state !== 'working') {
|
|
return false
|
|
}
|
|
if (next.payload.agentType !== 'claude') {
|
|
return false
|
|
}
|
|
// Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work.
|
|
return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName)
|
|
}
|
|
|
|
function paneCacheKeyTabId(key: string): string | null {
|
|
const paneKey = key.split('\0', 1)[0] ?? key
|
|
return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null
|
|
}
|
|
|
|
function paneCacheKeyMatchesTab(key: string, tabId: string): boolean {
|
|
return paneCacheKeyTabId(key) === tabId
|
|
}
|
|
|
|
function shouldKeepClaudePermissionVisible(
|
|
previous: EnrichedAgentHookEventPayload | undefined,
|
|
next: AgentHookEventPayload
|
|
): boolean {
|
|
if (
|
|
previous?.payload.agentType !== 'claude' ||
|
|
previous.payload.state !== 'waiting' ||
|
|
previous.hookEventName !== 'PermissionRequest' ||
|
|
next.payload.agentType !== 'claude' ||
|
|
next.payload.state !== 'working'
|
|
) {
|
|
return false
|
|
}
|
|
if (next.hasExplicitPrompt === true) {
|
|
return false
|
|
}
|
|
if (isClaudePermissionResumingApprovedTool(previous, next)) {
|
|
return false
|
|
}
|
|
// Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides.
|
|
if (isAskUserQuestionTool(previous.payload.toolName)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function isClaudePermissionResumingApprovedTool(
|
|
previous: EnrichedAgentHookEventPayload,
|
|
next: AgentHookEventPayload
|
|
): boolean {
|
|
const previousToolUseId = previous.toolUseId?.trim() || undefined
|
|
const nextToolUseId = next.toolUseId?.trim() || undefined
|
|
const previousAgentId = previous.toolAgentId?.trim() || undefined
|
|
const nextAgentId = next.toolAgentId?.trim() || undefined
|
|
const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined
|
|
const previousAgentType = previous.toolAgentType?.trim() || undefined
|
|
const nextAgentType = next.toolAgentType?.trim() || undefined
|
|
const hasMatchingConcreteAgentId =
|
|
previousAgentId !== undefined && previousAgentId === nextAgentId
|
|
const hasSameExplicitAgentType =
|
|
!hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType
|
|
const sameToolName =
|
|
previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName
|
|
const sameKnownToolInput =
|
|
previous.payload.toolInput !== undefined &&
|
|
previous.payload.toolInput === next.payload.toolInput
|
|
const sameUnknownInputFromConcreteAgent =
|
|
hasMatchingConcreteAgentId &&
|
|
previous.payload.toolInput === undefined &&
|
|
next.payload.toolInput === undefined
|
|
const hasMatchingToolUseId =
|
|
previousToolUseId !== undefined && previousToolUseId === nextToolUseId
|
|
const hasConflictingToolUseId =
|
|
previousToolUseId !== undefined &&
|
|
nextToolUseId !== undefined &&
|
|
previousToolUseId !== nextToolUseId
|
|
const sameUnknownInputFromToolUseId =
|
|
hasMatchingToolUseId &&
|
|
previous.payload.toolInput === undefined &&
|
|
next.payload.toolInput === undefined
|
|
|
|
return (
|
|
(next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') &&
|
|
nextToolUseId !== undefined &&
|
|
!hasConflictingToolUseId &&
|
|
// Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal.
|
|
(hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) &&
|
|
sameToolName &&
|
|
(sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId)
|
|
)
|
|
}
|
|
|
|
function shouldInheritClaudeToolUseIdForPermission(
|
|
previous: EnrichedAgentHookEventPayload | undefined,
|
|
next: AgentHookEventPayload
|
|
): boolean {
|
|
if (
|
|
previous?.payload.agentType !== 'claude' ||
|
|
previous.payload.state !== 'working' ||
|
|
previous.hookEventName !== 'PreToolUse' ||
|
|
typeof previous.toolUseId !== 'string' ||
|
|
previous.toolUseId.trim().length === 0 ||
|
|
next.payload.agentType !== 'claude' ||
|
|
next.payload.state !== 'waiting' ||
|
|
next.hookEventName !== 'PermissionRequest' ||
|
|
next.toolUseId !== undefined
|
|
) {
|
|
return false
|
|
}
|
|
const sameKnownToolInput =
|
|
previous.payload.toolInput !== undefined &&
|
|
previous.payload.toolInput === next.payload.toolInput
|
|
const sameUnknownToolInput =
|
|
previous.payload.toolInput === undefined && next.payload.toolInput === undefined
|
|
if (
|
|
previous.toolAgentId !== next.toolAgentId ||
|
|
previous.toolAgentType !== next.toolAgentType ||
|
|
previous.payload.toolName === undefined ||
|
|
previous.payload.toolName !== next.payload.toolName ||
|
|
(!sameKnownToolInput && !sameUnknownToolInput)
|
|
) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function attachClaudePermissionToolUseId(
|
|
previous: EnrichedAgentHookEventPayload | undefined,
|
|
next: AgentHookEventPayload
|
|
): AgentHookEventPayload {
|
|
const inheritedToolUseId = previous?.toolUseId
|
|
if (
|
|
!shouldInheritClaudeToolUseIdForPermission(previous, next) ||
|
|
typeof inheritedToolUseId !== 'string'
|
|
) {
|
|
return next
|
|
}
|
|
return {
|
|
...next,
|
|
// Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id.
|
|
toolUseId: inheritedToolUseId
|
|
}
|
|
}
|
|
|
|
export class AgentHookServer {
|
|
private server: ReturnType<typeof createServer> | null = null
|
|
private port = 0
|
|
private token = ''
|
|
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
|
|
private env = 'production'
|
|
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
|
|
private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null
|
|
private onPaneStatusCleared: PaneStatusClearListener | null = null
|
|
private statusChangeListeners = new Set<StatusChangeListener>()
|
|
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
|
|
private endpointDir: string | null = null
|
|
private endpointFilePathCache: string | null = null
|
|
private endpointFileWritten = false
|
|
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
|
|
private state: HookListenerState = createHookListenerState()
|
|
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
|
|
private runtimeObservedStatusPaneKeys = new Set<string>()
|
|
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
|
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
|
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
|
|
private lastStatusFilePath: string | null = null
|
|
// Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state.
|
|
private statusPersistTimer: ReturnType<typeof setTimeout> | null = null
|
|
private assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
private promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
|
|
private promptSentHashSalt = randomBytes(16).toString('hex')
|
|
private closedAgentStatusTabIds = new Set<string>()
|
|
private closedAgentStatusPaneKeys = new Set<string>()
|
|
private connectionTimestampWatermarkById = new Map<string, number>()
|
|
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
|
|
private lastWrittenJson: string | null = null
|
|
|
|
setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void {
|
|
this.onAgentStatus = listener
|
|
if (!listener) {
|
|
return
|
|
}
|
|
// Why: replay is best-effort per pane so one throwing listener can't starve the rest.
|
|
for (const payload of this.state.lastStatusByPaneKey.values()) {
|
|
try {
|
|
// Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it.
|
|
listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true })
|
|
} catch (err) {
|
|
console.error('[agent-hooks] replay listener threw', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly.
|
|
setClaudeStatusLineListener(
|
|
listener: ((event: ClaudeStatusLineRateLimits) => void) | null
|
|
): void {
|
|
this.onClaudeStatusLine = listener
|
|
}
|
|
|
|
subscribeStatusChanges(listener: StatusChangeListener): () => void {
|
|
this.statusChangeListeners.add(listener)
|
|
return () => {
|
|
this.statusChangeListeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
setPaneStatusClearListener(listener: PaneStatusClearListener | null): void {
|
|
this.onPaneStatusCleared = listener
|
|
}
|
|
|
|
/** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the
|
|
* dashboard catches up on hook events that fired during startup. */
|
|
getStatusSnapshot(): AgentStatusIpcPayload[] {
|
|
return Array.from(this.state.lastStatusByPaneKey.values(), (entry) =>
|
|
toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)
|
|
)
|
|
}
|
|
|
|
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
|
|
if (!isValidPaneKey(request.paneKey)) {
|
|
return false
|
|
}
|
|
if (!isAgentInterruptInputIntent(request.intent)) {
|
|
return false
|
|
}
|
|
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
if (!existing) {
|
|
return false
|
|
}
|
|
if (existing.providerSessionOnly) {
|
|
return false
|
|
}
|
|
const payload = existing.payload
|
|
const agentType: AgentType | undefined = payload.agentType
|
|
// Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn.
|
|
if (agentType === 'droid' && request.intent === 'ctrl-c') {
|
|
return false
|
|
}
|
|
// Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt.
|
|
if (
|
|
(agentType === 'opencode' || agentType === 'copilot') &&
|
|
request.intent === 'plain-escape' &&
|
|
request.inputCount !== 2
|
|
) {
|
|
return false
|
|
}
|
|
// Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook.
|
|
if (
|
|
payload.state !== 'working' ||
|
|
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
|
|
payload.prompt !== request.baselinePrompt ||
|
|
existing.receivedAt !== request.baselineUpdatedAt ||
|
|
existing.stateStartedAt !== request.baselineStateStartedAt ||
|
|
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
|
|
) {
|
|
return false
|
|
}
|
|
// Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows.
|
|
if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) {
|
|
return false
|
|
}
|
|
|
|
// Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane.
|
|
if (agentType === 'claude') {
|
|
markClaudeLeadTurnInterrupted(this.state, existing.paneKey)
|
|
}
|
|
if (agentType === 'codex') {
|
|
markCodexLeadTurnInterrupted(this.state, existing.paneKey)
|
|
}
|
|
const inferred = this.applyNormalizedStatus({
|
|
paneKey: existing.paneKey,
|
|
tabId: existing.tabId,
|
|
worktreeId: existing.worktreeId,
|
|
connectionId: existing.connectionId,
|
|
providerSession: existing.providerSession,
|
|
payload: {
|
|
state: 'done',
|
|
prompt: payload.prompt,
|
|
agentType,
|
|
...(payload.model ? { model: payload.model } : {}),
|
|
interrupted: true,
|
|
// Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore.
|
|
...(payload.subagents ? { subagents: payload.subagents } : {})
|
|
}
|
|
})
|
|
console.debug('[agent-hooks] inferred interrupted agent status', {
|
|
paneKey: inferred.paneKey,
|
|
agentType,
|
|
intent: request.intent
|
|
})
|
|
return true
|
|
}
|
|
|
|
/** Guarded fallback for a hook Claude never sends: answering AskUserQuestion produces no event, so re-validate the
|
|
* renderer's baseline against the cached status (a racing real hook wins) and synthesize the post-answer state. */
|
|
inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean {
|
|
if (!isValidPaneKey(request.paneKey)) {
|
|
return false
|
|
}
|
|
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
if (!existing) {
|
|
return false
|
|
}
|
|
const payload = existing.payload
|
|
// Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky.
|
|
if (
|
|
payload.agentType !== 'claude' ||
|
|
payload.state !== 'waiting' ||
|
|
!isAskUserQuestionTool(payload.toolName)
|
|
) {
|
|
return false
|
|
}
|
|
if (
|
|
payload.agentType !== request.baselineAgentType ||
|
|
payload.prompt !== request.baselinePrompt ||
|
|
existing.receivedAt !== request.baselineUpdatedAt ||
|
|
existing.stateStartedAt !== request.baselineStateStartedAt ||
|
|
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
|
|
) {
|
|
return false
|
|
}
|
|
// Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card.
|
|
const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey)
|
|
const inferred = this.applyNormalizedStatus({
|
|
paneKey: existing.paneKey,
|
|
tabId: existing.tabId,
|
|
worktreeId: existing.worktreeId,
|
|
connectionId: existing.connectionId,
|
|
providerSession: existing.providerSession,
|
|
payload: {
|
|
state: restored.state,
|
|
prompt: payload.prompt,
|
|
agentType: payload.agentType,
|
|
...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}),
|
|
...(payload.subagents ? { subagents: payload.subagents } : {})
|
|
}
|
|
})
|
|
console.debug('[agent-hooks] inferred answered question status', {
|
|
paneKey: inferred.paneKey,
|
|
state: inferred.payload.state
|
|
})
|
|
return true
|
|
}
|
|
|
|
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
|
|
return Array.from(this.state.lastStatusByPaneKey.entries()).flatMap(([paneKey, entry]) => {
|
|
const enriched = entry as EnrichedAgentHookEventPayload
|
|
return enriched.providerSessionOnly
|
|
? []
|
|
: [
|
|
{
|
|
state: enriched.payload.state,
|
|
receivedAt: enriched.receivedAt,
|
|
observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey)
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
private notifyStatusChangeListeners(): void {
|
|
if (this.statusChangeListeners.size === 0) {
|
|
return
|
|
}
|
|
const snapshot = this.getStatusChangeSnapshot()
|
|
for (const listener of this.statusChangeListeners) {
|
|
try {
|
|
listener(snapshot)
|
|
} catch (err) {
|
|
console.error('[agent-hooks] status-change listener threw', err)
|
|
}
|
|
}
|
|
}
|
|
|
|
private markTabClosedForAgentStatus(tabId: string): void {
|
|
// Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids.
|
|
this.closedAgentStatusTabIds.delete(tabId)
|
|
this.closedAgentStatusTabIds.add(tabId)
|
|
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
|
|
const oldest = this.closedAgentStatusTabIds.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
this.closedAgentStatusTabIds.delete(oldest)
|
|
}
|
|
}
|
|
|
|
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
|
|
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
|
if (
|
|
this.closedAgentStatusPaneKeys.has(paneKey) ||
|
|
this.closedAgentStatusPaneKeys.has(ownerPaneKey)
|
|
) {
|
|
return true
|
|
}
|
|
const tabId = parsePaneKey(ownerPaneKey)?.tabId
|
|
if (!tabId) {
|
|
return false
|
|
}
|
|
return this.closedAgentStatusTabIds.has(tabId)
|
|
}
|
|
|
|
private markPaneClosedForAgentStatus(paneKey: string): void {
|
|
this.closedAgentStatusPaneKeys.delete(paneKey)
|
|
this.closedAgentStatusPaneKeys.add(paneKey)
|
|
while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) {
|
|
const oldest = this.closedAgentStatusPaneKeys.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
this.closedAgentStatusPaneKeys.delete(oldest)
|
|
}
|
|
}
|
|
|
|
private attachStatusTiming(
|
|
payload: AgentHookEventPayload,
|
|
now = Date.now()
|
|
): EnrichedAgentHookEventPayload {
|
|
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
const commandCodeNewTurn =
|
|
previous !== undefined &&
|
|
isCommandCodeNewTurnWhileWorking({
|
|
agentType: payload.payload.agentType,
|
|
previousState: previous.payload.state,
|
|
incomingState: payload.payload.state,
|
|
previousPrompt: previous.payload.prompt,
|
|
incomingPrompt: payload.payload.prompt,
|
|
hasExplicitPrompt: payload.hasExplicitPrompt,
|
|
previousPromptInteractionKey: previous.promptInteractionKey,
|
|
incomingPromptInteractionKey: payload.promptInteractionKey
|
|
})
|
|
const stateStartedAt =
|
|
previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
|
|
? previous.stateStartedAt
|
|
: now
|
|
return {
|
|
...payload,
|
|
receivedAt: now,
|
|
stateStartedAt
|
|
}
|
|
}
|
|
|
|
private hashPromptForTelemetryDedupe(prompt: string): string {
|
|
return createHash('sha256')
|
|
.update(this.promptSentHashSalt)
|
|
.update('\0')
|
|
.update(prompt)
|
|
.digest('hex')
|
|
}
|
|
|
|
private maybeTrackAgentPromptSent(
|
|
payload: AgentHookEventPayload,
|
|
previousStatus: EnrichedAgentHookEventPayload | undefined
|
|
): void {
|
|
if (payload.isReplay === true || payload.hasExplicitPrompt !== true) {
|
|
return
|
|
}
|
|
const prompt = payload.payload.prompt?.trim() ?? ''
|
|
if (prompt.length === 0) {
|
|
return
|
|
}
|
|
const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType)
|
|
const promptHash = this.hashPromptForTelemetryDedupe(prompt)
|
|
const promptInteractionKey =
|
|
typeof payload.promptInteractionKey === 'string' &&
|
|
payload.promptInteractionKey.trim().length > 0
|
|
? payload.promptInteractionKey.trim()
|
|
: undefined
|
|
const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey)
|
|
const isCompletedTurnBoundary =
|
|
previousStatus?.payload.state === 'done' && payload.payload.state === 'working'
|
|
if (
|
|
previousDedupe?.agentKind === agentKind &&
|
|
previousDedupe.promptInteractionKey !== undefined &&
|
|
previousDedupe.promptInteractionKey === promptInteractionKey &&
|
|
(agentKind === 'opencode' || previousDedupe.promptHash === promptHash)
|
|
) {
|
|
return
|
|
}
|
|
if (
|
|
previousDedupe?.agentKind === agentKind &&
|
|
previousDedupe.promptHash === promptHash &&
|
|
!(
|
|
previousStatus?.payload.state === 'done' &&
|
|
payload.payload.state === 'done' &&
|
|
previousDedupe.promptInteractionKey !== undefined &&
|
|
promptInteractionKey !== undefined &&
|
|
previousDedupe.promptInteractionKey !== promptInteractionKey
|
|
) &&
|
|
!isCompletedTurnBoundary
|
|
) {
|
|
return
|
|
}
|
|
this.promptSentDedupeByPaneKey.set(payload.paneKey, {
|
|
agentKind,
|
|
promptHash,
|
|
promptInteractionKey
|
|
})
|
|
try {
|
|
// Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality.
|
|
track('agent_prompt_sent', {
|
|
agent_kind: agentKind,
|
|
launch_source: 'unknown',
|
|
request_kind: 'followup',
|
|
...getCohortAtEmit()
|
|
})
|
|
} catch (err) {
|
|
console.error('[agent-hooks] prompt-sent telemetry failed', err)
|
|
}
|
|
}
|
|
|
|
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
|
|
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
const connectionClearWatermark = payload.connectionId
|
|
? this.connectionTimestampWatermarkById.get(payload.connectionId)
|
|
: undefined
|
|
// Why: Date.now() can repeat across reconnect; a remote replay must sort strictly after its connection's transient clear.
|
|
const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1)
|
|
if (payload.connectionId) {
|
|
this.connectionTimestampWatermarkById.set(payload.connectionId, now)
|
|
}
|
|
if (payload.providerSessionOnly) {
|
|
// Why: Pi session_start replaces stale turn state and survives replay, but must not emit prompt telemetry or a fabricated status.
|
|
const enriched = this.attachStatusTiming(payload, now)
|
|
this.clearAssistantMessageRetry(enriched.paneKey)
|
|
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
|
|
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
this.onAgentStatus?.(enriched)
|
|
return enriched
|
|
}
|
|
const stateReconciledPayload =
|
|
payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName
|
|
? {
|
|
...payload,
|
|
payload: reconcileRemoteCodexState(
|
|
this.state,
|
|
payload.paneKey,
|
|
payload.hookEventName,
|
|
payload.toolAgentId,
|
|
payload.payload,
|
|
previous?.payload
|
|
)
|
|
}
|
|
: payload
|
|
const previousCodexRoot =
|
|
stateReconciledPayload.payload.agentType === 'codex' &&
|
|
stateReconciledPayload.toolAgentId &&
|
|
previous?.payload.agentType === 'codex'
|
|
? previous
|
|
: undefined
|
|
const preservedProviderSession = !stateReconciledPayload.providerSession
|
|
? previousCodexRoot?.providerSession
|
|
: undefined
|
|
const preservedRootModel = !stateReconciledPayload.payload.model
|
|
? previousCodexRoot?.payload.model
|
|
: undefined
|
|
// Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity.
|
|
const rootContextPreservingPayload =
|
|
preservedProviderSession || preservedRootModel
|
|
? {
|
|
...stateReconciledPayload,
|
|
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
|
|
payload: preservedRootModel
|
|
? { ...stateReconciledPayload.payload, model: preservedRootModel }
|
|
: stateReconciledPayload.payload
|
|
}
|
|
: stateReconciledPayload
|
|
const identity = resolveAgentStatusIdentity({
|
|
existing: previous
|
|
? {
|
|
agentType: previous.payload.agentType,
|
|
state: previous.payload.state,
|
|
updatedAt: previous.receivedAt
|
|
}
|
|
: undefined,
|
|
incoming: rootContextPreservingPayload.payload.agentType,
|
|
now
|
|
})
|
|
if (
|
|
previous &&
|
|
shouldSuppressInheritedTerminalStatus({
|
|
inheritedFromActivePane: identity.inheritedFromActivePane,
|
|
incomingState: rootContextPreservingPayload.payload.state
|
|
})
|
|
) {
|
|
return previous
|
|
}
|
|
const identityResolvedPayload =
|
|
identity.agentType === rootContextPreservingPayload.payload.agentType
|
|
? rootContextPreservingPayload
|
|
: {
|
|
...rootContextPreservingPayload,
|
|
payload: {
|
|
...rootContextPreservingPayload.payload,
|
|
agentType: identity.agentType
|
|
}
|
|
}
|
|
const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload)
|
|
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
|
|
return previous
|
|
}
|
|
// Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row.
|
|
if (
|
|
previous?.payload.state === 'done' &&
|
|
previous.payload.interrupted === true &&
|
|
effectivePayload.payload.state === 'done' &&
|
|
previous.payload.agentType === effectivePayload.payload.agentType &&
|
|
previous.payload.prompt === effectivePayload.payload.prompt &&
|
|
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
|
|
) {
|
|
return previous
|
|
}
|
|
if (
|
|
previous?.payload.state === 'done' &&
|
|
previous.payload.interrupted === true &&
|
|
effectivePayload.payload.state === 'working' &&
|
|
previous.payload.agentType === effectivePayload.payload.agentType &&
|
|
previous.payload.prompt === effectivePayload.payload.prompt &&
|
|
(effectivePayload.isReplay === true ||
|
|
isToolProgressWorkingAfterInterrupt(effectivePayload) ||
|
|
(effectivePayload.hasExplicitPrompt !== true &&
|
|
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
|
|
) {
|
|
return previous
|
|
}
|
|
if (
|
|
effectivePayload.payload.state !== 'done' ||
|
|
effectivePayload.payload.lastAssistantMessage
|
|
) {
|
|
this.clearAssistantMessageRetry(effectivePayload.paneKey)
|
|
}
|
|
if (!identity.inheritedFromActivePane) {
|
|
this.maybeTrackAgentPromptSent(effectivePayload, previous)
|
|
}
|
|
const enriched = this.attachStatusTiming(effectivePayload, now)
|
|
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
|
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
this.onAgentStatus?.(enriched)
|
|
return enriched
|
|
}
|
|
|
|
private clearAssistantMessageRetry(paneKey: string): void {
|
|
const timer = this.assistantMessageRetryTimers.get(paneKey)
|
|
if (!timer) {
|
|
return
|
|
}
|
|
clearTimeout(timer)
|
|
this.assistantMessageRetryTimers.delete(paneKey)
|
|
}
|
|
|
|
private scheduleAssistantMessageRetry(
|
|
source: AgentHookSource,
|
|
body: unknown,
|
|
original: EnrichedAgentHookEventPayload,
|
|
attempt = 1,
|
|
discoveryReady = false
|
|
): void {
|
|
if (
|
|
original.payload.lastAssistantMessage ||
|
|
!hasPendingAgentResultText(source, body) ||
|
|
attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS
|
|
) {
|
|
return
|
|
}
|
|
this.clearAssistantMessageRetry(original.paneKey)
|
|
if (!discoveryReady) {
|
|
const discovery = preparePendingGrokResultDiscovery(source, body)
|
|
if (discovery) {
|
|
// Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically.
|
|
void discovery
|
|
.then(() => {
|
|
if (this.server) {
|
|
this.applyAssistantMessageRetry(source, body, original, 1, true)
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error('[agent-hooks] Grok result discovery failed:', err)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
const timer = setTimeout(() => {
|
|
try {
|
|
this.assistantMessageRetryTimers.delete(original.paneKey)
|
|
this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady)
|
|
} catch (err) {
|
|
console.error('[agent-hooks] assistant message retry failed:', err)
|
|
}
|
|
}, ASSISTANT_MESSAGE_RETRY_MS)
|
|
this.assistantMessageRetryTimers.set(original.paneKey, timer)
|
|
if (typeof timer.unref === 'function') {
|
|
timer.unref()
|
|
}
|
|
}
|
|
|
|
private applyAssistantMessageRetry(
|
|
source: AgentHookSource,
|
|
body: unknown,
|
|
original: EnrichedAgentHookEventPayload,
|
|
nextAttempt: number,
|
|
requireExactOriginal: boolean
|
|
): void {
|
|
const current = this.state.lastStatusByPaneKey.get(original.paneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
if (
|
|
!current ||
|
|
(requireExactOriginal && current !== original) ||
|
|
current.payload.agentType !== original.payload.agentType ||
|
|
current.payload.prompt !== original.payload.prompt ||
|
|
current.payload.lastAssistantMessage
|
|
) {
|
|
return
|
|
}
|
|
const normalized = normalizeHookPayload(this.state, source, body, this.env)
|
|
if (!normalized?.payload.lastAssistantMessage) {
|
|
this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal)
|
|
return
|
|
}
|
|
// Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed.
|
|
this.applyNormalizedStatus(normalized)
|
|
}
|
|
|
|
setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void {
|
|
this.paneKeyAliasPersistenceListener = listener
|
|
}
|
|
|
|
private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] {
|
|
return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) =>
|
|
entry.ptyId
|
|
? [
|
|
{
|
|
ptyId: entry.ptyId,
|
|
legacyPaneKey,
|
|
stablePaneKey: entry.stablePaneKey,
|
|
updatedAt: entry.updatedAt
|
|
}
|
|
]
|
|
: []
|
|
)
|
|
}
|
|
|
|
private notifyPaneKeyAliasPersistenceListener(): void {
|
|
this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases())
|
|
}
|
|
|
|
private boundPaneKeyAliases(): void {
|
|
while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) {
|
|
// Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup.
|
|
const oldestKey = this.legacyPaneKeyAliases.keys().next().value
|
|
if (!oldestKey) {
|
|
break
|
|
}
|
|
this.legacyPaneKeyAliases.delete(oldestKey)
|
|
}
|
|
}
|
|
|
|
private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string {
|
|
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
|
let fallbackPaneKey = paneKey
|
|
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
|
|
if (
|
|
entry.stablePaneKey === ownerPaneKey &&
|
|
(!ptyId || !entry.ptyId || entry.ptyId === ptyId)
|
|
) {
|
|
if (entry.authorityVerified) {
|
|
return physicalPaneKey
|
|
}
|
|
fallbackPaneKey = physicalPaneKey
|
|
}
|
|
}
|
|
return fallbackPaneKey
|
|
}
|
|
|
|
canTransferPaneAuthority(
|
|
fromPaneKey: string,
|
|
ptyId: string | undefined,
|
|
ownsPty: (physicalPaneKey: string, ptyId: string) => boolean
|
|
): boolean {
|
|
if (!isValidPaneKey(fromPaneKey)) {
|
|
return false
|
|
}
|
|
const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey)
|
|
const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId)
|
|
const alias = this.legacyPaneKeyAliases.get(physicalPaneKey)
|
|
if (ptyId) {
|
|
return Boolean(
|
|
(alias?.authorityVerified && alias.ptyId === ptyId) ||
|
|
ownsPty(physicalPaneKey, ptyId) ||
|
|
(ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId))
|
|
)
|
|
}
|
|
// Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias.
|
|
return alias?.authorityVerified === true
|
|
}
|
|
|
|
registerPaneKeyAlias(
|
|
legacyPaneKey: string,
|
|
stablePaneKey: string,
|
|
ptyId?: string,
|
|
updatedAt = Date.now(),
|
|
options?: { overwriteExisting?: boolean; authorityVerified?: boolean }
|
|
): void {
|
|
const legacy = parseLegacyNumericPaneKey(legacyPaneKey)
|
|
const stable = isValidPaneKey(stablePaneKey) ? parsePaneKey(stablePaneKey) : null
|
|
if (!legacy || !stable || legacy.tabId !== stable.tabId) {
|
|
return
|
|
}
|
|
const existing = this.legacyPaneKeyAliases.get(legacy.paneKey)
|
|
if (existing && options?.overwriteExisting === false) {
|
|
return
|
|
}
|
|
const normalizedPtyId =
|
|
typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId
|
|
const normalizedUpdatedAt =
|
|
Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now())
|
|
const authorityVerified = options?.authorityVerified ?? false
|
|
if (
|
|
existing &&
|
|
existing.stablePaneKey === stablePaneKey &&
|
|
existing.ptyId === (normalizedPtyId ?? null) &&
|
|
existing.updatedAt === normalizedUpdatedAt &&
|
|
existing.authorityVerified === authorityVerified
|
|
) {
|
|
return
|
|
}
|
|
this.legacyPaneKeyAliases.set(legacy.paneKey, {
|
|
stablePaneKey,
|
|
ptyId: normalizedPtyId ?? null,
|
|
updatedAt: normalizedUpdatedAt,
|
|
authorityVerified
|
|
})
|
|
this.boundPaneKeyAliases()
|
|
if (normalizedPtyId) {
|
|
this.notifyPaneKeyAliasPersistenceListener()
|
|
}
|
|
}
|
|
|
|
transferPaneAuthority(
|
|
fromPaneKey: string,
|
|
toPaneKey: string,
|
|
ptyId?: string,
|
|
updatedAt = Date.now(),
|
|
options?: { authorityVerified?: boolean }
|
|
): void {
|
|
if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) {
|
|
return
|
|
}
|
|
const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey)
|
|
const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId)
|
|
const existing = this.legacyPaneKeyAliases.get(physicalPaneKey)
|
|
const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null
|
|
const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey)
|
|
movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey)
|
|
const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
if (movedStatus) {
|
|
const owner = parsePaneKey(toPaneKey)
|
|
this.state.lastStatusByPaneKey.set(toPaneKey, {
|
|
...movedStatus,
|
|
paneKey: toPaneKey,
|
|
tabId: owner?.tabId
|
|
})
|
|
}
|
|
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
|
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
|
}
|
|
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
|
|
if (promptDedupe !== undefined) {
|
|
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
|
|
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
|
|
}
|
|
this.clearAssistantMessageRetry(previousOwnerPaneKey)
|
|
// Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
|
|
this.legacyPaneKeyAliases.set(physicalPaneKey, {
|
|
stablePaneKey: toPaneKey,
|
|
ptyId: normalizedPtyId,
|
|
updatedAt,
|
|
authorityVerified: options?.authorityVerified ?? true
|
|
})
|
|
this.boundPaneKeyAliases()
|
|
this.closedAgentStatusPaneKeys.delete(toPaneKey)
|
|
this.notifyPaneKeyAliasPersistenceListener()
|
|
if (hadStatus) {
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
}
|
|
}
|
|
|
|
retirePaneAuthority(paneKey: string): void {
|
|
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
|
const paneKeys = new Set([paneKey, ownerPaneKey])
|
|
let aliasChanged = false
|
|
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
|
|
if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) {
|
|
this.legacyPaneKeyAliases.delete(physicalPaneKey)
|
|
paneKeys.add(physicalPaneKey)
|
|
paneKeys.add(entry.stablePaneKey)
|
|
aliasChanged = true
|
|
}
|
|
}
|
|
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
|
for (const key of paneKeys) {
|
|
this.markPaneClosedForAgentStatus(key)
|
|
this.clearAssistantMessageRetry(key)
|
|
clearPaneCacheState(this.state, key)
|
|
this.runtimeObservedStatusPaneKeys.delete(key)
|
|
this.promptSentDedupeByPaneKey.delete(key)
|
|
}
|
|
if (aliasChanged) {
|
|
this.notifyPaneKeyAliasPersistenceListener()
|
|
}
|
|
if (hadStatus) {
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
}
|
|
}
|
|
|
|
clearPaneKeyAliasesForPty(
|
|
ptyId: string,
|
|
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
|
|
): void {
|
|
let aliasChanged = false
|
|
let statusChanged = false
|
|
const clearedStatusPaneKeys = new Set<string>()
|
|
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
|
if (entry.ptyId === ptyId) {
|
|
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
|
clearPaneCacheState(this.state, legacyPaneKey)
|
|
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
|
const shouldClearStablePaneKey =
|
|
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
|
|
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
|
|
statusChanged = true
|
|
clearedStatusPaneKeys.add(entry.stablePaneKey)
|
|
}
|
|
if (shouldClearStablePaneKey) {
|
|
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
|
|
clearPaneCacheState(this.state, entry.stablePaneKey)
|
|
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
|
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
|
|
}
|
|
aliasChanged = true
|
|
}
|
|
}
|
|
if (aliasChanged) {
|
|
this.notifyPaneKeyAliasPersistenceListener()
|
|
}
|
|
if (statusChanged) {
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
for (const paneKey of clearedStatusPaneKeys) {
|
|
this.onPaneStatusCleared?.({ paneKey })
|
|
}
|
|
}
|
|
}
|
|
|
|
private resolvePaneKeyAlias(paneKey: string): string {
|
|
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
|
|
}
|
|
|
|
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
|
|
if (typeof body !== 'object' || body === null) {
|
|
return body
|
|
}
|
|
const record = body as Record<string, unknown>
|
|
const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : ''
|
|
const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey
|
|
if (!stablePaneKey) {
|
|
return body
|
|
}
|
|
// Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner.
|
|
return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId }
|
|
}
|
|
|
|
ingestTerminalStatus(event: {
|
|
paneKey: string
|
|
tabId?: string
|
|
worktreeId?: string
|
|
connectionId?: string | null
|
|
payload: ParsedAgentStatusPayload
|
|
}): void {
|
|
const physicalPaneKey = event.paneKey.trim()
|
|
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
|
|
const parsedPaneKey = parsePaneKey(paneKey)
|
|
if (paneKey.length === 0) {
|
|
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
|
return
|
|
}
|
|
if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) {
|
|
return
|
|
}
|
|
const reportedTabId =
|
|
event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined
|
|
if (
|
|
paneKey === physicalPaneKey &&
|
|
reportedTabId !== undefined &&
|
|
reportedTabId !== parsedPaneKey.tabId
|
|
) {
|
|
return
|
|
}
|
|
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
|
|
if (this.shouldSuppressClosedTabStatus(paneKey)) {
|
|
return
|
|
}
|
|
const worktreeId =
|
|
event.worktreeId !== undefined && event.worktreeId.trim().length > 0
|
|
? event.worktreeId.trim()
|
|
: undefined
|
|
const connectionId =
|
|
typeof event.connectionId === 'string' && event.connectionId.trim().length > 0
|
|
? event.connectionId.trim()
|
|
: null
|
|
const previous = this.state.lastStatusByPaneKey.get(paneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
if (
|
|
previous?.connectionId === connectionId &&
|
|
previous.tabId === tabId &&
|
|
previous.worktreeId === worktreeId &&
|
|
equivalentParsedAgentStatusPayload(previous.payload, event.payload)
|
|
) {
|
|
return
|
|
}
|
|
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
|
|
this.applyNormalizedStatus({
|
|
paneKey,
|
|
tabId,
|
|
worktreeId,
|
|
connectionId,
|
|
payload: event.payload
|
|
})
|
|
}
|
|
|
|
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
|
|
ingestRemote(
|
|
envelope: {
|
|
paneKey: string
|
|
tabId?: string
|
|
worktreeId?: string
|
|
env?: string
|
|
version?: string
|
|
launchToken?: string
|
|
hasExplicitPrompt?: boolean
|
|
promptInteractionKey?: string
|
|
hookEventName?: string
|
|
toolUseId?: string
|
|
toolAgentId?: string
|
|
toolAgentType?: string
|
|
providerSession?: unknown
|
|
providerSessionOnly?: unknown
|
|
isReplay?: boolean
|
|
payload: unknown
|
|
},
|
|
connectionId: string
|
|
): void {
|
|
// Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches.
|
|
if (typeof connectionId !== 'string') {
|
|
return
|
|
}
|
|
const trimmedConnectionId = connectionId.trim()
|
|
if (trimmedConnectionId.length === 0) {
|
|
return
|
|
}
|
|
if (!envelope || typeof envelope.paneKey !== 'string') {
|
|
return
|
|
}
|
|
// Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge.
|
|
const physicalPaneKey = envelope.paneKey.trim()
|
|
const paneKey = this.resolvePaneKeyAlias(physicalPaneKey)
|
|
const parsedPaneKey = parsePaneKey(paneKey)
|
|
if (paneKey.length === 0) {
|
|
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
|
|
return
|
|
}
|
|
if (paneKey.length > MAX_PANE_KEY_LEN) {
|
|
return
|
|
}
|
|
if (!parsedPaneKey) {
|
|
return
|
|
}
|
|
if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') {
|
|
return
|
|
}
|
|
if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') {
|
|
return
|
|
}
|
|
// Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined.
|
|
const reportedTabId =
|
|
envelope.tabId !== undefined && envelope.tabId.trim().length > 0
|
|
? envelope.tabId.trim()
|
|
: undefined
|
|
if (
|
|
paneKey === physicalPaneKey &&
|
|
reportedTabId !== undefined &&
|
|
reportedTabId !== parsedPaneKey.tabId
|
|
) {
|
|
return
|
|
}
|
|
const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId
|
|
if (this.shouldSuppressClosedTabStatus(paneKey)) {
|
|
return
|
|
}
|
|
const worktreeId =
|
|
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
|
|
? envelope.worktreeId.trim()
|
|
: undefined
|
|
const hookEventName =
|
|
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
|
|
? envelope.hookEventName.trim()
|
|
: undefined
|
|
const promptInteractionKey =
|
|
typeof envelope.promptInteractionKey === 'string' &&
|
|
envelope.promptInteractionKey.trim().length > 0
|
|
? envelope.promptInteractionKey.trim()
|
|
: undefined
|
|
const toolUseId =
|
|
typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0
|
|
? envelope.toolUseId.trim()
|
|
: undefined
|
|
const toolAgentId =
|
|
typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0
|
|
? envelope.toolAgentId.trim()
|
|
: undefined
|
|
const toolAgentType =
|
|
typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0
|
|
? envelope.toolAgentType.trim()
|
|
: undefined
|
|
const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined
|
|
// Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed).
|
|
const normalizedPayload = normalizeAgentStatusPayload(envelope.payload)
|
|
if (!normalizedPayload) {
|
|
return
|
|
}
|
|
if (
|
|
envelope.providerSessionOnly === true &&
|
|
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
|
|
) {
|
|
return
|
|
}
|
|
// Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected.
|
|
warnOnHookEnvOrVersionMismatch(this.state, {
|
|
version: envelope.version,
|
|
env: envelope.env,
|
|
expectedEnv: this.env
|
|
})
|
|
const event: AgentHookEventPayload = {
|
|
paneKey,
|
|
launchToken: envelope.launchToken,
|
|
tabId,
|
|
worktreeId,
|
|
connectionId: trimmedConnectionId,
|
|
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
|
|
promptInteractionKey,
|
|
hookEventName,
|
|
toolUseId,
|
|
toolAgentId,
|
|
toolAgentType,
|
|
providerSession,
|
|
providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined,
|
|
isReplay: envelope.isReplay === true ? true : undefined,
|
|
payload: normalizedPayload
|
|
}
|
|
this.applyNormalizedStatus(event)
|
|
}
|
|
|
|
async start(options?: {
|
|
env?: string
|
|
userDataPath?: string
|
|
endpointNamespace?: string
|
|
}): Promise<void> {
|
|
if (this.server) {
|
|
return
|
|
}
|
|
|
|
if (options?.env) {
|
|
this.env = options.env
|
|
}
|
|
if (options?.userDataPath) {
|
|
// Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect.
|
|
this.endpointDir = options.endpointNamespace
|
|
? join(options.userDataPath, 'agent-hooks', options.endpointNamespace)
|
|
: join(options.userDataPath, 'agent-hooks')
|
|
this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName())
|
|
this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME)
|
|
}
|
|
this.token = randomUUID()
|
|
this.endpointFileWritten = false
|
|
this.lastWrittenJson = null
|
|
// Why: hydrate before binding the listener so an early hook POST runs against a populated map.
|
|
if (this.lastStatusFilePath) {
|
|
this.hydrateLastStatusFromDisk()
|
|
}
|
|
this.server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
if (req.method !== 'POST') {
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (req.headers['x-orca-agent-hook-token'] !== this.token) {
|
|
res.writeHead(403)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
// Why: bound request time so a stalled client can't hold a socket open (slowloris).
|
|
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
|
|
req.destroy()
|
|
})
|
|
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
|
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
|
|
const statusLineEvent = parseClaudeStatusLineBody(body)
|
|
if (statusLineEvent) {
|
|
this.onClaudeStatusLine?.(statusLineEvent)
|
|
}
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
const source = resolveHookSource(pathname)
|
|
if (!source) {
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
trackEmptyPaneKeyHook(body)
|
|
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
|
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
|
|
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
|
|
const enriched = this.applyNormalizedStatus(normalized)
|
|
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
|
}
|
|
|
|
res.writeHead(204)
|
|
res.end()
|
|
} catch {
|
|
// Why: fail open — return success on malformed payloads so a broken hook never blocks the agent.
|
|
res.writeHead(204)
|
|
res.end()
|
|
}
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
// Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event.
|
|
const onStartupError = (err: Error): void => {
|
|
this.server?.off('listening', onListening)
|
|
reject(err)
|
|
}
|
|
const onListening = (): void => {
|
|
this.server?.off('error', onStartupError)
|
|
this.server?.on('error', (err) => {
|
|
console.error('[agent-hooks] server error', err)
|
|
})
|
|
const address = this.server!.address()
|
|
if (address && typeof address === 'object') {
|
|
this.port = address.port
|
|
}
|
|
this.maybeWriteEndpointFile()
|
|
resolve()
|
|
}
|
|
this.server!.once('error', onStartupError)
|
|
this.server!.listen(0, '127.0.0.1', onListening)
|
|
})
|
|
}
|
|
|
|
stop(): void {
|
|
// Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch.
|
|
this.flushStatusPersistSync()
|
|
this.server?.close()
|
|
this.server = null
|
|
this.port = 0
|
|
this.token = ''
|
|
this.env = 'production'
|
|
this.onAgentStatus = null
|
|
this.onPaneStatusCleared = null
|
|
for (const timer of this.assistantMessageRetryTimers.values()) {
|
|
clearTimeout(timer)
|
|
}
|
|
this.assistantMessageRetryTimers.clear()
|
|
// Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca.
|
|
this.endpointDir = null
|
|
this.endpointFilePathCache = null
|
|
this.endpointFileWritten = false
|
|
this.lastStatusFilePath = null
|
|
this.lastWrittenJson = null
|
|
this.runtimeObservedStatusPaneKeys.clear()
|
|
this.promptSentDedupeByPaneKey.clear()
|
|
this.closedAgentStatusTabIds.clear()
|
|
this.closedAgentStatusPaneKeys.clear()
|
|
this.connectionTimestampWatermarkById.clear()
|
|
this.legacyPaneKeyAliases.clear()
|
|
clearAllListenerCaches(this.state)
|
|
this.notifyStatusChangeListeners()
|
|
}
|
|
|
|
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
|
|
dropStatusEntry(paneKey: string): void {
|
|
if (!this.deleteStatusEntry(paneKey)) {
|
|
return
|
|
}
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
}
|
|
|
|
/** Clear statuses proven to belong to one lost SSH transport. */
|
|
clearStatusEntriesForConnection(connectionId: string): void {
|
|
const normalizedConnectionId = connectionId.trim()
|
|
if (normalizedConnectionId.length === 0) {
|
|
return
|
|
}
|
|
const clearedAt = Math.max(
|
|
Date.now(),
|
|
(this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1
|
|
)
|
|
this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt)
|
|
let statusChanged = false
|
|
for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) {
|
|
const entry = rawEntry as EnrichedAgentHookEventPayload
|
|
// Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown.
|
|
if (entry.connectionId !== normalizedConnectionId) {
|
|
continue
|
|
}
|
|
const deleted = this.deleteStatusEntry(paneKey)
|
|
if (deleted) {
|
|
statusChanged = true
|
|
if (deleted.payload.agentType === 'codex') {
|
|
// Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children.
|
|
this.state.codexSubagentRosterByPaneKey.delete(paneKey)
|
|
this.state.codexLeadStateByPaneKey.delete(paneKey)
|
|
}
|
|
}
|
|
}
|
|
if (statusChanged) {
|
|
// Why: persist/notify once — one disconnect can own many panes.
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
}
|
|
// Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row.
|
|
this.onPaneStatusCleared?.({
|
|
transient: true,
|
|
connectionId: normalizedConnectionId,
|
|
clearedAt
|
|
})
|
|
}
|
|
|
|
private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null {
|
|
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
|
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
|
|
| EnrichedAgentHookEventPayload
|
|
| undefined
|
|
if (!existing) {
|
|
return null
|
|
}
|
|
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
|
this.clearAssistantMessageRetry(resolvedPaneKey)
|
|
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
|
if (existing.payload.state === 'done') {
|
|
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
|
}
|
|
return existing
|
|
}
|
|
|
|
dropStatusEntriesByTabPrefix(tabId: string): void {
|
|
this.markTabClosedForAgentStatus(tabId)
|
|
const paneKeysToClear = new Set<string>()
|
|
for (const key of this.state.lastStatusByPaneKey.keys()) {
|
|
if (paneCacheKeyMatchesTab(key, tabId)) {
|
|
paneKeysToClear.add(key)
|
|
}
|
|
}
|
|
for (const key of this.state.lastPromptByPaneKey.keys()) {
|
|
if (paneCacheKeyMatchesTab(key, tabId)) {
|
|
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
|
}
|
|
}
|
|
for (const key of this.state.lastToolByPaneKey.keys()) {
|
|
if (paneCacheKeyMatchesTab(key, tabId)) {
|
|
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
|
}
|
|
}
|
|
for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) {
|
|
if (paneCacheKeyMatchesTab(key, tabId)) {
|
|
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
|
}
|
|
}
|
|
for (const key of this.state.ampCompletedCacheKeys) {
|
|
if (paneCacheKeyMatchesTab(key, tabId)) {
|
|
paneKeysToClear.add(key.split('\0', 1)[0] ?? key)
|
|
}
|
|
}
|
|
for (const paneKey of this.runtimeObservedStatusPaneKeys) {
|
|
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
|
|
paneKeysToClear.add(paneKey)
|
|
}
|
|
}
|
|
for (const paneKey of this.promptSentDedupeByPaneKey.keys()) {
|
|
if (paneCacheKeyMatchesTab(paneKey, tabId)) {
|
|
paneKeysToClear.add(paneKey)
|
|
}
|
|
}
|
|
|
|
let aliasChanged = false
|
|
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
|
const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)
|
|
if (ownerMatches) {
|
|
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
|
paneKeysToClear.add(legacyPaneKey)
|
|
paneKeysToClear.add(entry.stablePaneKey)
|
|
this.markPaneClosedForAgentStatus(legacyPaneKey)
|
|
this.markPaneClosedForAgentStatus(entry.stablePaneKey)
|
|
aliasChanged = true
|
|
}
|
|
}
|
|
|
|
let statusChanged = false
|
|
for (const paneKey of paneKeysToClear) {
|
|
if (this.state.lastStatusByPaneKey.has(paneKey)) {
|
|
statusChanged = true
|
|
}
|
|
this.clearAssistantMessageRetry(paneKey)
|
|
clearPaneCacheState(this.state, paneKey)
|
|
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
|
this.promptSentDedupeByPaneKey.delete(paneKey)
|
|
}
|
|
if (aliasChanged) {
|
|
this.notifyPaneKeyAliasPersistenceListener()
|
|
}
|
|
if (statusChanged) {
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
}
|
|
}
|
|
|
|
clearPaneState(paneKey: string): void {
|
|
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
|
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
|
|
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
|
this.clearAssistantMessageRetry(resolvedPaneKey)
|
|
clearPaneCacheState(this.state, resolvedPaneKey)
|
|
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
|
let clearedAlias = false
|
|
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
|
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
|
|
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
|
clearPaneCacheState(this.state, legacyPaneKey)
|
|
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
|
clearedAlias = true
|
|
}
|
|
}
|
|
if (clearedAlias) {
|
|
this.notifyPaneKeyAliasPersistenceListener()
|
|
}
|
|
if (hadStatus) {
|
|
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
|
this.scheduleStatusPersist()
|
|
this.notifyStatusChangeListeners()
|
|
this.onPaneStatusCleared?.({ paneKey: resolvedPaneKey })
|
|
}
|
|
}
|
|
|
|
buildPtyEnv(): Record<string, string> {
|
|
if (this.port <= 0 || !this.token) {
|
|
return {}
|
|
}
|
|
|
|
const env: Record<string, string> = {
|
|
ORCA_AGENT_HOOK_PORT: String(this.port),
|
|
ORCA_AGENT_HOOK_TOKEN: this.token,
|
|
ORCA_AGENT_HOOK_ENV: this.env,
|
|
ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION
|
|
}
|
|
// Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks.
|
|
if (this.endpointFileWritten && this.endpointFilePathCache) {
|
|
env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache
|
|
}
|
|
return env
|
|
}
|
|
|
|
get endpointFilePath(): string | null {
|
|
return this.endpointFilePathCache
|
|
}
|
|
|
|
/** Test/diagnostic accessor for the on-disk last-status file path. */
|
|
get lastStatusPath(): string | null {
|
|
return this.lastStatusFilePath
|
|
}
|
|
|
|
private maybeWriteEndpointFile(): void {
|
|
if (!this.endpointDir || !this.endpointFilePathCache) {
|
|
return
|
|
}
|
|
this.endpointFileWritten = false
|
|
const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, {
|
|
port: this.port,
|
|
token: this.token,
|
|
env: this.env,
|
|
version: ORCA_HOOK_PROTOCOL_VERSION
|
|
})
|
|
this.endpointFileWritten = ok
|
|
}
|
|
|
|
private hydrateLastStatusFromDisk(): void {
|
|
if (!this.lastStatusFilePath) {
|
|
return
|
|
}
|
|
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
|
|
this.state.lastStatusByPaneKey.clear()
|
|
let raw: string
|
|
try {
|
|
raw = readFileSync(this.lastStatusFilePath, 'utf8')
|
|
} catch (err) {
|
|
// Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn.
|
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
console.warn('[agent-hooks] failed to read last-status file:', err)
|
|
}
|
|
return
|
|
}
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(raw)
|
|
} catch {
|
|
console.warn('[agent-hooks] last-status file is not valid JSON; ignoring')
|
|
return
|
|
}
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
console.warn('[agent-hooks] last-status file is not an object; ignoring')
|
|
return
|
|
}
|
|
const file = parsed as Partial<LastStatusFile>
|
|
if (file.version !== LAST_STATUS_FILE_VERSION) {
|
|
console.warn(
|
|
`[agent-hooks] last-status file version mismatch (${String(
|
|
file.version
|
|
)} != ${LAST_STATUS_FILE_VERSION}); ignoring`
|
|
)
|
|
return
|
|
}
|
|
const entries = file.entries
|
|
if (typeof entries !== 'object' || entries === null) {
|
|
console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring')
|
|
return
|
|
}
|
|
let hydrated = 0
|
|
let dropped = 0
|
|
let prunedLegacyClaudeSubagents = 0
|
|
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
|
|
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
|
|
for (const [paneKey, rawEntry] of Object.entries(entries)) {
|
|
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
|
const rawResolvedEntry =
|
|
resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null
|
|
? rawEntry
|
|
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
|
|
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
|
|
if (entry && entry.receivedAt >= ttlCutoff) {
|
|
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
|
|
if (hydratedPayload !== entry.payload) {
|
|
prunedLegacyClaudeSubagents +=
|
|
(entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0)
|
|
entry.payload = hydratedPayload
|
|
}
|
|
this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry)
|
|
if (entry.connectionId) {
|
|
// Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state.
|
|
const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId)
|
|
this.connectionTimestampWatermarkById.set(
|
|
entry.connectionId,
|
|
Math.max(previousWatermark ?? -1, entry.receivedAt)
|
|
)
|
|
}
|
|
// Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds.
|
|
if (entry.payload.agentType === 'codex') {
|
|
seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload)
|
|
} else if (entry.payload.agentType === 'claude' && entry.payload.subagents) {
|
|
seedClaudeSubagentRosterFromSnapshots(
|
|
this.state,
|
|
resolvedPaneKey,
|
|
entry.payload.subagents
|
|
)
|
|
}
|
|
hydrated += 1
|
|
} else {
|
|
dropped += 1
|
|
}
|
|
}
|
|
if (dropped > 0) {
|
|
console.warn(
|
|
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
|
|
)
|
|
}
|
|
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
|
|
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
|
|
this.runStatusPersist()
|
|
} else if (hydrated > 0) {
|
|
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
|
|
this.lastWrittenJson = raw
|
|
}
|
|
}
|
|
|
|
private serializeStatusFile(): string {
|
|
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
|
|
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
|
|
// Why: never persist invalid keys (matches the hydrate-path invariant).
|
|
if (!isValidPaneKey(paneKey)) {
|
|
continue
|
|
}
|
|
const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload
|
|
entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload
|
|
}
|
|
const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries }
|
|
return JSON.stringify(file)
|
|
}
|
|
|
|
private scheduleStatusPersist(): void {
|
|
if (!this.lastStatusFilePath) {
|
|
return
|
|
}
|
|
// Why: reset the timer each call so the write fires only after the last event in a burst.
|
|
if (this.statusPersistTimer) {
|
|
clearTimeout(this.statusPersistTimer)
|
|
}
|
|
this.statusPersistTimer = setTimeout(() => {
|
|
this.statusPersistTimer = null
|
|
this.runStatusPersist()
|
|
}, STATUS_PERSIST_DEBOUNCE_MS)
|
|
// Why: don't keep the event loop alive just for a status flush — quit already flushes sync.
|
|
if (typeof this.statusPersistTimer.unref === 'function') {
|
|
this.statusPersistTimer.unref()
|
|
}
|
|
}
|
|
|
|
flushStatusPersistSync(): void {
|
|
if (this.statusPersistTimer) {
|
|
clearTimeout(this.statusPersistTimer)
|
|
this.statusPersistTimer = null
|
|
}
|
|
if (!this.lastStatusFilePath) {
|
|
return
|
|
}
|
|
this.runStatusPersist()
|
|
}
|
|
|
|
private runStatusPersist(): void {
|
|
if (!this.lastStatusFilePath || !this.endpointDir) {
|
|
return
|
|
}
|
|
const json = this.serializeStatusFile()
|
|
if (json === this.lastWrittenJson) {
|
|
return
|
|
}
|
|
const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`)
|
|
let tmpWritten = false
|
|
try {
|
|
mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 })
|
|
if (process.platform !== 'win32') {
|
|
try {
|
|
chmodSync(this.endpointDir, 0o700)
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
writeFileSync(tmpPath, json, { mode: 0o600 })
|
|
tmpWritten = true
|
|
renameSync(tmpPath, this.lastStatusFilePath)
|
|
this.lastWrittenJson = json
|
|
} catch (err) {
|
|
console.warn('[agent-hooks] failed to write last-status file:', err)
|
|
if (tmpWritten) {
|
|
try {
|
|
unlinkSync(tmpPath)
|
|
} catch {
|
|
// tmp already gone
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */
|
|
_getStateForTests(): HookListenerState {
|
|
return this.state
|
|
}
|
|
|
|
_resetPromptSentDedupeForTests(): void {
|
|
this.promptSentDedupeByPaneKey.clear()
|
|
}
|
|
|
|
_resetConnectionTimestampWatermarksForTests(): void {
|
|
this.connectionTimestampWatermarkById.clear()
|
|
}
|
|
}
|
|
|
|
export const agentHookServer = new AgentHookServer()
|
|
|
|
// Why: exported for test coverage of the per-agent field extractors.
|
|
export const _internals = {
|
|
// Why: bind the test-helper to the singleton's state so tests exercise the live caches.
|
|
normalizeHookPayload: (
|
|
source: AgentHookSource,
|
|
body: unknown,
|
|
expectedEnv: string
|
|
): AgentHookEventPayload | null =>
|
|
normalizeHookPayload(agentHookServer._getStateForTests(), source, body, expectedEnv),
|
|
parseFormEncodedBody,
|
|
resetCachesForTests: (): void => {
|
|
clearAllListenerCaches(agentHookServer._getStateForTests())
|
|
agentHookServer._resetPromptSentDedupeForTests()
|
|
agentHookServer._resetConnectionTimestampWatermarksForTests()
|
|
}
|
|
}
|