feat(agent-hooks): shared listener + relay adapter (PR 1/N for SSH agent status)#1678
Merged
Merged
Conversation
Adds the shared `agent-hook-relay.ts` module with the `agent.hook` JSON-RPC notification envelope, the `agent_hook.requestReplay` / `agent_hook.installPlugins` method names, and the `ORCA_FEATURE_REMOTE_AGENT_HOOKS` flag helper. Promotes `AgentHookSource` to `shared/` so the relay can import it without dragging Electron in. Threads a `connectionId: string | null` field through `AgentHookEventPayload`, the `agentStatus:set` IPC contract, and the renderer-bound preload listener. Local hook posts stamp `null`; the relay-forwarded path will stamp from `mux` identity in a later commit. Renderer uses the stamp for stale-event filtering when an SSH connection tears down with notifications still in flight. See docs/design/agent-status-over-ssh.md §1, §5, §8 (commit #1). Co-authored-by: Orca <help@stably.ai>
Extracts the listener internals (request parsing, payload normalization, endpoint-file writing, per-CLI extractors, warn-once Sets, slowloris timer helper, request size cap, paneKey caches) from `src/main/agent-hooks/server.ts` into a new transport-agnostic `src/shared/agent-hook-listener.ts`. The shared module uses only Node builtins (no Electron) so it is safe to import from `src/relay/`. Adds `src/relay/agent-hook-server.ts` — a thin HTTP-loopback adapter that wires the shared listener to a `forward(envelope)` callback so `relay.ts` can re-emit each parsed payload as an `agent.hook` JSON-RPC notification on the existing SshChannelMultiplexer. The adapter owns: - 127.0.0.1:0 socket + bearer-token auth, identical shape to the local server - per-paneKey last-payload cache + replayCachedPayloadsForPanes() for the request-driven replay path used after `--connect` reattach (see §5 Path 3) - clearPaneState(paneKey) for PTY-exit eviction (symmetric with local server) - buildPtyEnv() / endpoint-file writing for relay-spawned PTYs Orca's `AgentHookServer` is now a ~200-LoC adapter over the shared listener that owns the IPC fanout, listener replay, and `ingestRemote(envelope, connId)` entry point that bypasses the HTTP path for relay-forwarded events. See docs/design/agent-status-over-ssh.md §3, §8 (commit #2). Co-authored-by: Orca <help@stably.ai>
src/preload/index.ts already passes through `connectionId?: string | null` from main, but the PreloadApi declaration in api-types.ts was missing the field. Align the type with the runtime contract so renderer call sites can read connectionId without an `as` cast. Co-authored-by: Orca <help@stably.ai>
…leanup - ingestRemote: re-run normalizeAgentStatusPayload at trust boundary; trim+validate connectionId/paneKey/tabId/worktreeId - relay: preserve source/env/version through replay via sidecar map; drop sourceFromAgentType fallback that mis-tagged unknown agents - shared listener: exhaustive switch+never on AgentHookSource dispatch chains; extractPromptText returns trimmed values; export MAX_PANE_KEY_LEN - preload: tighten connectionId from optional to required (always sent) - main IPC: reorder spread so explicit envelope fields win on collision Co-authored-by: Orca <help@stably.ai>
The design RFC was useful for authoring this PR series but doesn't belong in-tree — keeping it here would freeze line-number references and design prose against future churn. Folding it into the PR description instead. Co-authored-by: Orca <help@stably.ai>
Declares `env?: string` and `version?: string` on the `ingestRemote` envelope parameter so PR2 only needs to add the `warnOnHookEnvOrVersionMismatch` callsite, not also widen the type. The fields are forwarded verbatim from the agent CLI POST body on the remote and let Orca's warn-once cross-build / dev-vs-prod diagnostics fire identically on remote-sourced events. Type-only addition; no runtime consumer in this PR. Co-authored-by: Orca <help@stably.ai>
brennanb2025
added a commit
that referenced
this pull request
May 11, 2026
Resolves the agent-hooks refactor collision from #1678 (shared listener + relay adapter). The refactor extracted listener internals into `src/shared/agent-hook-listener.ts` and replaced server.ts's module-level `lastStatusByPaneKey` Map with `state.lastStatusByPaneKey` on a shared `HookListenerState`. This branch's persistence layer is re-anchored on the new shape: - `AgentStatusIpcPayload` now carries `connectionId: string | null` (from main) alongside `receivedAt` / `stateStartedAt` (from this branch). - `src/main/agent-hooks/server.ts` rewritten as the slim adapter (~720 LoC) over the shared listener: defines a server-process-only `EnrichedAgentHookEventPayload = AgentHookEventPayload & {receivedAt, stateStartedAt}` stored in `state.lastStatusByPaneKey` (the shared module never reads this map, so the extra fields ride along untouched), keeps `last-status.json` v2 hydrate / sanitize / TTL / atomic-write / drop semantics. The new HTTP and `ingestRemote` paths both run through `attachStatusTiming` before caching. - `src/main/index.ts` IPC fanout forwards the union: connectionId + receivedAt + stateStartedAt + ...payload. - `src/preload/{api-types,index}.ts` keep the typed `AgentStatusIpcPayload` surface (which now subsumes both branches' fields). - `src/main/agent-hooks/server.test.ts`: persistence tests preserved as-is; ingestRemote tests from main re-laxed from `toHaveBeenCalledWith({...})` to `expect.objectContaining({...})` so the listener's enriched payload doesn't fail strict equality. Verified: pnpm tc:node + tc:web clean; 776/776 vitest tests pass across agent-hooks, shared listener, relay, IPC handler, and renderer agent-status slice + ui slice. tc:cli has pre-existing TS6307 errors on origin/main that are not introduced by this merge. Co-authored-by: Orca <help@stably.ai>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First PR in a multi-PR effort to make per-pane agent status (
working/blocked/waiting/done) work for SSH-hosted agent CLIs. Today the entire hook pipeline is gated off for SSH becauseagentHookServerbinds127.0.0.1:0on the user's laptop — there is no path for a remoteclaude.sh/codex/gemini/cursor/opencode/pievent to reach Orca, so SSH panes never light up the dashboard, dock badges, or sidekicks.The eventual design hosts a sibling hook server inside the relay process on the remote box and forwards each parsed event back to Orca as an
agent.hookJSON-RPC notification on the existingSshChannelMultiplexer.This PR is the maximally-revertable foundation slice. No behavior change ships — both new transports are dead code, gated behind future commits.
What lands here
src/shared/agent-hook-relay.ts— wire envelope for the futureagent.hooknotification, theagent_hook.requestReplay/agent_hook.installPluginsmethod names, and theORCA_FEATURE_REMOTE_AGENT_HOOKSflag helper. PromotesAgentHookSourcehere so the relay can import it without dragging Electron in.src/shared/agent-hook-listener.ts(new, ~1.1k LoC extracted) — listener internals (request parsing, payload normalization, endpoint-file writing, per-CLI extractors, warn-once Sets, slowloris timer helper, request size cap, paneKey caches). Pure Node builtins; no Electron dependency, so the relay can consume it.src/main/agent-hooks/server.ts— slimmed from ~1500 LoC to ~280. Now an Orca-process adapter over the shared listener: owns the loopback HTTP socket + bearer-token auth, IPC fanout (setListener/lastStatusByPaneKeyreplay), and a newingestRemote(envelope, connectionId)entry point that bypasses HTTP for relay-forwarded events.ingestRemotehas no caller in this PR — added now so the type contract is stable for the next PR.src/relay/agent-hook-server.ts(new, ~260 LoC) — thin HTTP-loopback adapter that wires the shared listener to aforward(envelope)callback so a futurerelay.tschange can re-emit each parsed payload as anagent.hooknotification. Never instantiated yet — defined for the next PR.connectionIdplumbing throughAgentHookEventPayload+agentStatus:setIPC + preload — local path stampsnull; the relay-forwarded path will stamp it frommuxidentity in the next PR. The renderer field is exposed in types but not yet read.Why this is safe to merge
RelayAgentHookServerhas zero instantiations andAgentHookServer.ingestRemotehas zero callers — confirmed by grep. The only live path is the local loopback HTTP server, which is a pure refactor over the extracted shared listener.server.test.ts,agent-hook-listener.test.ts,agent-hook-relay.test.ts,agent-hook-server.test.tson both sides). The pre-existingserver.test.tssuite that pins live HTTP behavior still passes against the new module shape.ingestRemote(commit 352138c): paneKey/tabId/worktreeId trim+cap, connectionId validation, and exhaustiveswitch + neveronAgentHookSourcedispatch chains. Final defense-in-depth strategy (whether to re-normalize payloads or trust relay-side normalization) is settled in PR2.What is intentionally not here (next PRs)
relay.tsinstantiatingRelayAgentHookServerand callingdispatcher.notify('agent.hook', envelope).pty-handler.tsinjectingORCA_AGENT_HOOK_*env vars into relay-spawned PTYs.mux.onNotificationhandler that filters for'agent.hook'and callsagentHookServer.ingestRemote(params, connectionId).src/main/ipc/pty.tsSSH-guard relaxation forORCA_PANE_KEY/ORCA_TAB_ID/ORCA_WORKTREE_ID.agent_hook.installPluginsfor OpenCode/Pi plugin source sync.The full design RFC is in commit 1a4e42c (and dropped from the working tree in 6b904bf since it was authoring scaffolding rather than docs we want frozen in-tree).
Test plan
pn vitest run src/main/agent-hooks src/shared/agent-hook src/relay/agent-hook— 119/119 passMade with Orca 🐋