diff --git a/src/main/agent-hooks/server-ingest-remote.test.ts b/src/main/agent-hooks/server-ingest-remote.test.ts index bb4802fb0ed..8cbabdfb5b5 100644 --- a/src/main/agent-hooks/server-ingest-remote.test.ts +++ b/src/main/agent-hooks/server-ingest-remote.test.ts @@ -609,6 +609,40 @@ describe('AgentHookServer ingestRemote', () => { expect(listener).not.toHaveBeenCalled() }) + it('rejects non-string launchToken', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + launchToken: 123 as unknown as string, + payload: { state: 'working', prompt: 'p', agentType: 'opencode' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + + it('rejects malformed hookProcessId', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookProcessId: 'not-a-process-id', + payload: { state: 'working', prompt: 'p', agentType: 'opencode' } + }, + 'conn-1' + ) + expect(listener).not.toHaveBeenCalled() + }) + it('rejects empty paneKey after trim', () => { const server = new AgentHookServer() const payload = parseAgentStatusPayload( diff --git a/src/main/agent-hooks/server-opencode-lifecycle.test.ts b/src/main/agent-hooks/server-opencode-lifecycle.test.ts index 93e31908818..4b5ea8a64e3 100644 --- a/src/main/agent-hooks/server-opencode-lifecycle.test.ts +++ b/src/main/agent-hooks/server-opencode-lifecycle.test.ts @@ -4,6 +4,8 @@ import { AgentHookServer } from './server' const PANE = makePaneKey('tab-opencode', '11111111-1111-4111-8111-111111111111') const TARGET_PANE = makePaneKey('tab-opencode', '22222222-2222-4222-8222-222222222222') +const OLD_HOOK_PROCESS_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const FRESH_HOOK_PROCESS_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' describe('AgentHookServer OpenCode lifecycle', () => { const servers: AgentHookServer[] = [] @@ -21,7 +23,8 @@ describe('AgentHookServer OpenCode lifecycle', () => { payload: Record, launchToken: string, paneKey?: string, - source?: 'opencode' | 'mimo-code' + source?: 'opencode' | 'mimo-code', + hookProcessId?: string ) => Promise }> { const server = new AgentHookServer() @@ -30,7 +33,7 @@ describe('AgentHookServer OpenCode lifecycle', () => { const env = server.buildPtyEnv() return { server, - post: (payload, launchToken, paneKey = PANE, source = 'opencode') => + post: (payload, launchToken, paneKey = PANE, source = 'opencode', hookProcessId) => fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${source}`, { method: 'POST', headers: { @@ -40,6 +43,7 @@ describe('AgentHookServer OpenCode lifecycle', () => { body: JSON.stringify({ paneKey, launchToken, + hookProcessId, tabId: 'tab-opencode', worktreeId: 'wt-opencode', env: 'production', @@ -128,6 +132,59 @@ describe('AgentHookServer OpenCode lifecycle', () => { ]) }) + it('fences a stale process without blocking an existing-session resume', async () => { + const { server, post } = await setup() + await post( + { hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale process' }, + 'shared-token', + PANE, + 'opencode', + OLD_HOOK_PROCESS_ID + ) + server.retirePaneAuthority(PANE) + await post( + { hook_event_name: 'SessionStart', sessionID: 'fresh' }, + 'shared-token', + PANE, + 'opencode', + FRESH_HOOK_PROCESS_ID + ) + await post( + { hook_event_name: 'SessionBusy', sessionID: 'fresh' }, + 'shared-token', + PANE, + 'opencode', + FRESH_HOOK_PROCESS_ID + ) + await post( + { + hook_event_name: 'MessagePart', + role: 'user', + text: 'resume old', + messageID: 'resume', + sessionID: 'old' + }, + 'shared-token', + PANE, + 'opencode', + FRESH_HOOK_PROCESS_ID + ) + await post( + { hook_event_name: 'SessionBusy', sessionID: 'old', prompt: 'stale process' }, + 'shared-token', + PANE, + 'opencode', + OLD_HOOK_PROCESS_ID + ) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + prompt: 'resume old', + providerSession: { key: 'session_id', id: 'old' } + }) + ]) + }) + it('clears the destination token fence when pane authority transfers', async () => { const { server, post } = await setup() await post( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ecb2fb2a02e..281f879997b 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -55,6 +55,7 @@ import { } from '../../shared/claude-subagent-roster' import { isAgentHookSource, + normalizeAgentHookProcessId, restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay' @@ -123,6 +124,7 @@ type NormalizedLocalHook = { type PersistedAgentHookEventPayload = Omit< EnrichedAgentHookEventPayload, | 'claudeRunningNonAgentTask' + | 'hookProcessId' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed' @@ -742,6 +744,7 @@ export class AgentHookServer { private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() private restartedStatusLaunchTokenHashByPaneKey = new Map() + private restartedStatusHookProcessIdByPaneKey = new Map() private connectionTimestampWatermarkById = new Map() // 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 @@ -1130,6 +1133,7 @@ export class AgentHookServer { source?: AgentHookSource hasExplicitPrompt?: boolean launchToken?: string + hookProcessId?: string } ): 'accept' | 'restart' | 'suppress' { const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) @@ -1142,8 +1146,9 @@ export class AgentHookServer { } if (!paneRetired) { const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) - // Why: deferred retirement lets a new process start in a still-authorized pane, so - // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + const processFence = this.restartedStatusHookProcessIdByPaneKey.get(ownerPaneKey) + // Why: SessionStart is the only process boundary safe to re-fence while the pane is live; + // prompts recur within one OpenCode process and cannot distinguish a resume from stale work. if ( event?.hookEventName === 'SessionStart' && event.isReplay !== true && @@ -1155,9 +1160,21 @@ export class AgentHookServer { ownerPaneKey, createHash('sha256').update(startedLaunchToken).digest('hex') ) + const startedHookProcessId = normalizeAgentHookProcessId(event.hookProcessId) + if (startedHookProcessId) { + this.restartedStatusHookProcessIdByPaneKey.set(ownerPaneKey, startedHookProcessId) + } else { + this.restartedStatusHookProcessIdByPaneKey.delete(ownerPaneKey) + } return 'accept' } } + if (event && processFence) { + const hookProcessId = normalizeAgentHookProcessId(event.hookProcessId) + if (hookProcessId !== processFence) { + return 'suppress' + } + } if (event && tokenFence) { const launchToken = event.launchToken?.trim() if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { @@ -1190,6 +1207,12 @@ export class AgentHookServer { } else { this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) } + const hookProcessId = normalizeAgentHookProcessId(event.hookProcessId) + if (hookProcessId) { + this.restartedStatusHookProcessIdByPaneKey.set(ownerPaneKey, hookProcessId) + } else { + this.restartedStatusHookProcessIdByPaneKey.delete(ownerPaneKey) + } return 'restart' } return 'suppress' @@ -1854,6 +1877,13 @@ export class AgentHookServer { if (restartedTokenHash) { this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) } + const restartedHookProcessId = + this.restartedStatusHookProcessIdByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusHookProcessIdByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusHookProcessIdByPaneKey.delete(toPaneKey) + if (restartedHookProcessId) { + this.restartedStatusHookProcessIdByPaneKey.set(toPaneKey, restartedHookProcessId) + } const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) if (activeTurnCompletedAt !== undefined) { this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) @@ -1915,6 +1945,7 @@ export class AgentHookServer { for (const key of paneKeys) { this.markPaneClosedForAgentStatus(key) this.restartedStatusLaunchTokenHashByPaneKey.delete(key) + this.restartedStatusHookProcessIdByPaneKey.delete(key) this.clearAssistantMessageRetry(key) this.clearCodexSubagentPoll(key) clearPaneCacheState(this.state, key) @@ -2225,6 +2256,7 @@ export class AgentHookServer { env?: string version?: string launchToken?: string + hookProcessId?: unknown hasExplicitPrompt?: boolean promptInteractionKey?: string hookEventName?: string @@ -2276,6 +2308,9 @@ export class AgentHookServer { if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { return } + if (envelope.launchToken !== undefined && typeof envelope.launchToken !== '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 @@ -2301,12 +2336,17 @@ export class AgentHookServer { (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') ? envelope.compactTrigger : undefined + const hookProcessId = normalizeAgentHookProcessId(envelope.hookProcessId) + if (envelope.hookProcessId !== undefined && !hookProcessId) { + return + } const statusDisposition = this.getAgentStatusDisposition(paneKey, { hookEventName, isReplay: envelope.isReplay === true, source, hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken + launchToken: envelope.launchToken, + hookProcessId }) if (statusDisposition === 'suppress') { return @@ -2414,6 +2454,7 @@ export class AgentHookServer { paneKey, source, launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, + hookProcessId, tabId, worktreeId, connectionId: trimmedConnectionId, @@ -2527,7 +2568,8 @@ export class AgentHookServer { isReplay: normalized.event.isReplay, source: normalized.event.source, hasExplicitPrompt: normalized.event.hasExplicitPrompt, - launchToken: normalized.event.launchToken + launchToken: normalized.event.launchToken, + hookProcessId: normalized.event.hookProcessId }) : 'suppress' if (normalized.event && statusDisposition !== 'suppress') { @@ -2622,6 +2664,7 @@ export class AgentHookServer { this.closedAgentStatusTabIds.clear() this.closedAgentStatusPaneKeys.clear() this.restartedStatusLaunchTokenHashByPaneKey.clear() + this.restartedStatusHookProcessIdByPaneKey.clear() this.retiredPaneFencesByKey.clear() this.connectionTimestampWatermarkById.clear() this.legacyPaneKeyAliases.clear() @@ -2800,6 +2843,7 @@ export class AgentHookServer { this.currentAuthorityObservations.delete(paneKey) this.promptSentDedupeByPaneKey.delete(paneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + this.restartedStatusHookProcessIdByPaneKey.delete(paneKey) } if (aliasChanged) { this.notifyPaneKeyAliasPersistenceListener() @@ -2822,6 +2866,7 @@ export class AgentHookServer { this.currentAuthorityObservations.delete(resolvedPaneKey) this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + this.restartedStatusHookProcessIdByPaneKey.delete(resolvedPaneKey) let clearedAlias = false for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { if (stablePaneKey.stablePaneKey === resolvedPaneKey) { @@ -2833,6 +2878,7 @@ export class AgentHookServer { this.currentAuthorityObservations.delete(legacyPaneKey) this.promptSentDedupeByPaneKey.delete(legacyPaneKey) this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) + this.restartedStatusHookProcessIdByPaneKey.delete(legacyPaneKey) clearedAlias = true } } @@ -3182,6 +3228,7 @@ export class AgentHookServer { const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true const { claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + hookProcessId: _hookProcessId, promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. restoredUnconfirmed: _restoredUnconfirmed, diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts index 5e87a90991a..bf7672ab4f2 100644 --- a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -147,6 +147,31 @@ describe('OpenCode plugin lifecycle delivery', () => { expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }]) }) + it('uses one hook process id across lifecycle posts', async () => { + const hookProcessIds: string[] = [] + globalThis.fetch = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { + hookProcessId: string + payload: RecordedPost + } + hookProcessIds.push(body.hookProcessId) + posts.push(body.payload) + return new Response(null, { status: 204 }) + }) as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ + event: { type: 'session.created', properties: { info: { id: 'root' } } } + }) + await handler({ event: status('busy') }) + + expect(hookProcessIds).toHaveLength(2) + expect(new Set(hookProcessIds).size).toBe(1) + expect(hookProcessIds[0]).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + ) + }) + it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => { let releaseFirstLookup: (() => void) | undefined const firstLookup = new Promise((resolve) => { @@ -741,6 +766,56 @@ describe('OpenCode plugin lifecycle delivery', () => { expect(names().at(-1)).toBe('SessionIdle') }) + it('keeps an active root busy when another root session is created', async () => { + const sessions = [{ id: 'root-a' }, { id: 'root-b' }] + let releaseLookup: (() => void) | undefined + const blockedLookup = new Promise((resolve) => { + releaseLookup = resolve + }) + const list = vi.fn(async () => { + await blockedLookup + return { data: sessions } + }) + const handler = await loadHandler(list) + + const busy = handler({ event: status('busy', 'root-a') }) + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(1)) + const created = handler({ + event: { type: 'session.created', properties: { info: { id: 'root-b' } } } + }) + releaseLookup?.() + await Promise.all([busy, created]) + + expect(names()).toEqual(['SessionBusy']) + }) + + it('drops an old queued preview before a new root boundary', async () => { + vi.useFakeTimers() + const hooks = await loadHooks(async () => ({ data: [{ id: 'root-a' }, { id: 'root-b' }] })) + await hooks.event({ + event: { + type: 'message.updated', + properties: { sessionID: 'root-a', info: { id: 'assistant-message', role: 'assistant' } } + } + }) + const part = (text: string): PluginEvent => ({ + type: 'message.part.updated', + properties: { + sessionID: 'root-a', + part: { type: 'text', text, messageID: 'assistant-message' } + } + }) + await hooks.event({ event: part('first preview') }) + await hooks.event({ event: part('queued preview') }) + + await hooks.event({ + event: { type: 'session.created', properties: { info: { id: 'root-b' } } } + }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(names()).toEqual(['MessagePart', 'SessionStart']) + }) + it('reasserts Busy through the refreshed endpoint after a live text delta', async () => { const deliveries: { url: string; token: string | null }[] = [] globalThis.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index f06680477a1..09c0164fa48 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -51,6 +51,7 @@ export function getOpenCodeFamilyPluginSource( "// OpenCode's Node process (not Orca's) and has no access to server.ts's", '// equivalent warnedVersions / warnedEnvs Sets.', 'let warnedBadEndpoint = false;', + 'const hookProcessId = globalThis.crypto.randomUUID();', '', '// Why: message.part.updated can fire many times per second during a', '// streaming assistant reply, and each post() calls resolveHookCoords()', @@ -390,6 +391,7 @@ export function getOpenCodeFamilyPluginSource( ' const body = JSON.stringify({', ' paneKey,', ' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",', + ' hookProcessId,', ' tabId: process.env.ORCA_TAB_ID || "",', ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', ' env: coords.env,', @@ -884,9 +886,12 @@ export function getOpenCodeFamilyPluginSource( ' const info = event.properties?.info;', ' if (!info?.id || info.parentID) return;', ' rememberSessionRoot(info.id, info.id);', - ' await enqueueLifecycle(() =>', - ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', - ' );', + ' await enqueueLifecycle(async () => {', + ' if (disposed || currentAttention() || latestBusyOwner()) return;', + ' stateArrivalRevision += 1;', + ' await flushPendingAssistantPart();', + ' return post("SessionStart", { sessionID: info.id });', + ' });', ' return;', ' }', '' diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index eb59d0a56fa..03e2d251b2f 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1490,6 +1490,7 @@ export class SshRelaySession { const envelope = params as { paneKey?: unknown launchToken?: unknown + hookProcessId?: unknown tabId?: unknown worktreeId?: unknown env?: unknown @@ -1519,6 +1520,7 @@ export class SshRelaySession { { paneKey: envelope.paneKey, launchToken: typeof envelope.launchToken === 'string' ? envelope.launchToken : undefined, + hookProcessId: envelope.hookProcessId, tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, env: typeof envelope.env === 'string' ? envelope.env : undefined, diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec6..93374381c39 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -18,6 +18,7 @@ export function buildRelayHookEnvelope( source, paneKey: event.paneKey, ...(event.launchToken ? { launchToken: event.launchToken } : {}), + ...(event.hookProcessId ? { hookProcessId: event.hookProcessId } : {}), tabId: event.tabId, worktreeId: event.worktreeId, connectionId: null, diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3458814ed5b..ca00176d9e3 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -106,6 +106,31 @@ describe('RelayAgentHookServer', () => { } }) + it('forwards a validated hook process id', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/opencode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hookProcessId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + payload: { hook_event_name: 'SessionBusy', sessionID: 'session-1' } + }) + }) + + expect(forward.mock.calls[0][0].hookProcessId).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + } finally { + server.stop() + } + }) + it('rejects requests with the wrong bearer token (403)', async () => { const forward = vi.fn() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 0aed317b31e..47577a97d58 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -55,7 +55,11 @@ import { type CodexSubagentTranscriptState } from './codex-subagent-transcript' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' -import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' +import { + normalizeAgentHookProcessId, + REMOTE_AGENT_HOOK_ENV, + type AgentHookSource +} from './agent-hook-relay' import { agentProviderSessionsEqual, extractAgentProviderSession, @@ -332,6 +336,8 @@ export type AgentHookEventPayload = { source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string + /** Process-lifetime identity generated by the managed hook plugin. */ + hookProcessId?: string tabId?: string worktreeId?: string /** SSH connection the event arrived on, or null for local. Only `ingestRemote` can stamp it — the loopback HTTP path has no mux identity — and receivers key off it to drop @@ -4406,6 +4412,7 @@ export function normalizeHookPayload( } const worktreeId = readStringField(record, 'worktreeId') const launchToken = readStringField(record, 'launchToken') + const hookProcessId = normalizeAgentHookProcessId(record.hookProcessId) const hookPayloadRecord = hookPayload as Record if (source === 'claude') { @@ -4609,6 +4616,7 @@ export function normalizeHookPayload( paneKey, source, launchToken, + hookProcessId, tabId, worktreeId, // Why: normalization is transport-agnostic — only ingestRemote knows the mux identity to stamp here. diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 0ea80999766..3b3abe5497b 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -63,6 +63,16 @@ export function isAgentHookSource(value: unknown): value is AgentHookSource { return typeof value === 'string' && AGENT_HOOK_SOURCE_SET.has(value) } +export function normalizeAgentHookProcessId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed) + ? trimmed + : undefined +} + /** 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. */ export const REMOTE_AGENT_HOOK_ENV = 'remote' as const @@ -73,6 +83,8 @@ export type AgentHookRelayEnvelope = { paneKey: string /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string + /** Process-lifetime identity generated by the managed hook plugin. */ + hookProcessId?: string tabId?: string worktreeId?: string /** Always `null` on the wire — relay does not know Orca's local connectionId. */