Skip to content

feat(agent-hooks): shared listener + relay adapter (PR 1/N for SSH agent status)#1678

Merged
brennanb2025 merged 7 commits into
mainfrom
brennanb2025/agent-status-ssh-pr1
May 11, 2026
Merged

feat(agent-hooks): shared listener + relay adapter (PR 1/N for SSH agent status)#1678
brennanb2025 merged 7 commits into
mainfrom
brennanb2025/agent-status-ssh-pr1

Conversation

@brennanb2025
Copy link
Copy Markdown
Contributor

@brennanb2025 brennanb2025 commented May 11, 2026

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 because agentHookServer binds 127.0.0.1:0 on the user's laptop — there is no path for a remote claude.sh / codex / gemini / cursor / opencode / pi event 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.hook JSON-RPC notification on the existing SshChannelMultiplexer.

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 future agent.hook notification, the agent_hook.requestReplay / agent_hook.installPlugins method names, and the ORCA_FEATURE_REMOTE_AGENT_HOOKS flag helper. Promotes AgentHookSource here 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 / lastStatusByPaneKey replay), and a new ingestRemote(envelope, connectionId) entry point that bypasses HTTP for relay-forwarded events. ingestRemote has 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 a forward(envelope) callback so a future relay.ts change can re-emit each parsed payload as an agent.hook notification. Never instantiated yet — defined for the next PR.
  • connectionId plumbing through AgentHookEventPayload + agentStatus:set IPC + preload — local path stamps null; the relay-forwarded path will stamp it from mux identity in the next PR. The renderer field is exposed in types but not yet read.

Why this is safe to merge

  • Both new transports are dead code in this PR. RelayAgentHookServer has zero instantiations and AgentHookServer.ingestRemote has 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.
  • 119 vitest cases pass across the six touched files (server.test.ts, agent-hook-listener.test.ts, agent-hook-relay.test.ts, agent-hook-server.test.ts on both sides). The pre-existing server.test.ts suite that pins live HTTP behavior still passes against the new module shape.
  • Trust-boundary hardening on ingestRemote (commit 352138c): paneKey/tabId/worktreeId trim+cap, connectionId validation, and exhaustive switch + never on AgentHookSource dispatch 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.ts instantiating RelayAgentHookServer and calling dispatcher.notify('agent.hook', envelope).
  • pty-handler.ts injecting ORCA_AGENT_HOOK_* env vars into relay-spawned PTYs.
  • Orca-side mux.onNotification handler that filters for 'agent.hook' and calls agentHookServer.ingestRemote(params, connectionId).
  • src/main/ipc/pty.ts SSH-guard relaxation for ORCA_PANE_KEY / ORCA_TAB_ID / ORCA_WORKTREE_ID.
  • agent_hook.installPlugins for OpenCode/Pi plugin source sync.
  • Per-CLI remote settings.json installer for Claude/Codex/Gemini/Cursor.

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-hook119/119 pass
  • Smoke local agent status: spawn a Claude/Codex pane locally, observe `working` → `done` transitions in the dashboard (regression check on the refactor)
  • Confirm SSH panes still gated off (no behavior change for the SSH path in this PR)

Made with Orca 🐋

brennanb2025 and others added 7 commits May 10, 2026 13:05
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 brennanb2025 merged commit 1041ab4 into main May 11, 2026
2 checks passed
@brennanb2025 brennanb2025 deleted the brennanb2025/agent-status-ssh-pr1 branch May 11, 2026 04:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant