Skip to content

feat(server): add web (REST) hook provider - #389

Open
tbrandenburg wants to merge 3 commits into
pixel-agents-hq:mainfrom
tbrandenburg:feat/web-provider-v2
Open

feat(server): add web (REST) hook provider#389
tbrandenburg wants to merge 3 commits into
pixel-agents-hq:mainfrom
tbrandenburg:feat/web-provider-v2

Conversation

@tbrandenburg

Copy link
Copy Markdown

Stacked on #388 (refactor(server): resolve hook provider by providerId) — please merge that one first; this PR's diff will shrink to just what's below once #388 lands and this branch is rebased.

What

Adds a second HookProvider (id: 'web'), driven purely by HTTP requests, so an agent character can be created and animated with curl — no CLI, no transcript file, no terminal. Both claude and web are always live simultaneously at their own /api/hooks/:providerId routes once #388's registry is in place.

Why

  1. Any tool can drive the office. A CI job, a shell script, a bot, or a non-Claude agent framework can render itself as a character by POSTing lifecycle events — no hook installation, no transcript format to reverse-engineer.
  2. Validates the provider abstraction. core/src/provider.ts explicitly defers provider-type taxonomy work until "a real second provider... actually lands, derived from that provider's needs rather than speculation." This is that provider, exercising the abstraction at its thinnest: no file fallback, no team, no terminal.

Changes

  • server/src/providers/hook/web/: the provider. normalizeHookEvent covers all 8 AgentEvent kinds reachable via hooks for a non-team CLI (sessionStart, sessionEnd, toolStart, toolEnd, turnEnd, permissionRequest, subagentStart, subagentEnd); subagentTurnEnd (Agent-Teams-only) and progress (JSONL-fallback-only) have no equivalent here, by design. installHooks/uninstallHooks are no-ops, areHooksInstalled is always true — there's nothing to install, the HTTP call itself is the hook. Free-form tool vocabulary (empty readingTools/subagentToolNames/permissionExemptTools sets): unlike Claude's closed set of built-in tools, any external script/tool can be the caller here. A caller-supplied status field overrides the default Using <toolName> display text.
  • hookEventHandler.ts fix: subagentStart/subagentEnd dispatch was gated behind provider.team, which silently dropped sub-agent events for any team-less provider (this one has no team extension). Added an explicit-id fast path — providers that supply a real parentToolId/toolId directly on the normalized event (unlike Claude's 'current' sentinel, which requires JSONL-populated activeToolNames to resolve) create/clear the sub-agent character directly. Claude's existing team-gated logic is byte-for-byte unchanged, still reached via the 'current' sentinel branch — verified via the full pre-existing Agent-Teams test suite staying green.
  • clientMessageHandler.ts: providerCapabilities now unions readingTools/subagentToolNames across every registered provider instead of reading only Claude's.
  • Tests: web.test.ts (normalizeHookEvent per event kind + malformed input), providerRegistry.test.ts/hookEventHandler.test.ts extended with real multi-provider coverage (claude vs. web reach different code) and the explicit-id subagent fast path. e2e/tests/standalone/web-provider.spec.ts drives a full agent lifecycle over real HTTP (sendHookEvent against /api/hooks/web, the same sanctioned direct-hook-call pattern standalone/hooks.spec.ts already uses for Claude) covering all 8 in-scope kinds, observed via Playwright against a real browser — no VS Code, no mocked CLI.
  • Dev ergonomics: scripts/web-agent.sh (reads port/token from ~/.pixel-agents/server.json, wraps the curl call) and server/manual-web-events.http (REST-Client sample mirroring the existing Claude one).

Testing

  • npm run lint / npm run check-types — clean (0 errors; 1 pre-existing unrelated webview warning)
  • npm run asyncapi:validate / npm run asyncapi:generate — valid, zero diff (no new client/server message types)
  • npm run e2e:inventory — regenerated, diff is exactly the new spec's entry
  • npm run build — succeeds
  • npm test (server + webview) — 414/414 server tests pass, 52/52 webview tests pass
  • npm run e2e -- e2e/tests/standalone/hooks.spec.ts e2e/tests/standalone/web-provider.spec.ts — both pass (Claude's existing standalone hooks spec has no regression; the new web-provider spec passes end-to-end)
  • Manually verified with a real running standalone server + scripts/web-agent.sh/curl: spawned multiple agents, drove tool/sub-agent/permission/turn events, watched them animate and reseat in a live browser session.

Tom Brandenburg added 3 commits August 14, 2026 21:46
POST /api/hooks/:providerId accepted and validated a providerId in the URL,
but HookEventHandler.handleEvent(_providerId, event) never read it -- every
hook POST reached the same hardcoded Claude provider instance regardless of
the id in the URL. This adds a real registry and makes providerId meaningful:

- server/src/providers/index.ts: Map<string, HookProvider> + getProvider(id),
  falling back to the default (Claude) for any unregistered id so existing
  callers keep working exactly as before. getAllProviders() for future
  cross-provider concerns (e.g. capability unions).
- HookEventHandler now resolves the provider per event by providerId instead
  of a constructor-captured singleton. protocolVersion mismatches are now
  checked per-provider-id with warn-once logging (was constructor-time,
  single-provider only).
- AgentRuntime wires HookEventHandler through the shared providerRegistry;
  its own constructor still takes one 'primary' HookProvider for the
  Claude-specific file-fallback/Agent-Teams singleton wiring, which stays a
  single-provider concern until a provider with a file fallback or team
  extension actually exists.
- Tests: providerRegistry.test.ts (resolution + fallback), and a
  'provider registry routing' suite in hookEventHandler.test.ts proving two
  distinct providerIds reach two distinct provider instances end-to-end
  (via a minimal synthetic second provider, standing in until a real second
  provider lands) and that unknown-provider / protocol-mismatch cases are
  dropped with a single warning.

No behavior change for the only provider that exists today (Claude): all
383 server tests pass, lint/types/build/asyncapi/e2e-inventory all clean
with zero drift.
Adds a second HookProvider (id: 'web') driven purely by HTTP requests, so an
agent character can be created and animated with curl -- no CLI, no
transcript file, no terminal. Built on top of the provider-registry refactor
(previous PR): both 'claude' and 'web' are always live simultaneously at
their own /api/hooks/:providerId routes.

- server/src/providers/hook/web/: the HookProvider. normalizeHookEvent covers
  all 8 AgentEvent kinds reachable via hooks in a non-team CLI (sessionStart,
  sessionEnd, toolStart, toolEnd, turnEnd, permissionRequest, subagentStart,
  subagentEnd); subagentTurnEnd (Agent-Teams-only) and progress
  (JSONL-fallback-only) have no equivalent here by design. installHooks/
  uninstallHooks are no-ops and areHooksInstalled is always true -- there's
  nothing to install, the HTTP call itself is the hook. Free-form tool
  vocabulary (empty readingTools/subagentToolNames/permissionExemptTools
  Sets): unlike Claude's closed set of built-in tools, any external
  script/tool can be the caller here. A caller-supplied 'status' field
  overrides the default 'Using <toolName>' display text.

- fix(hookEventHandler): subagentStart/subagentEnd dispatch was gated behind
  provider.team, which silently dropped sub-agent events for any team-less
  provider (the web provider has no team extension). Added an explicit-id
  fast path: providers that supply a real parentToolId/toolId on the
  normalized event (unlike Claude's 'current' sentinel, which requires
  JSONL-populated activeToolNames to resolve) create/clear the sub-agent
  character directly. Claude's existing team-gated logic is unchanged --
  still reached exactly as before via the 'current' sentinel branch.

- clientMessageHandler: providerCapabilities now unions readingTools/
  subagentToolNames across every registered provider, not just Claude's, so
  a future provider's own tool vocabulary (if any) still gets classified
  correctly by the webview.

- Tests: server/__tests__/web.test.ts (normalizeHookEvent per event kind,
  malformed input, no-op installer), providerRegistry.test.ts extended for
  the web provider, hookEventHandler.test.ts extended with the multi-provider
  routing proof (claude vs web reach different code) and the explicit-id
  subagent fast-path test. e2e/tests/standalone/web-provider.spec.ts drives a
  full agent lifecycle over real HTTP (sendHookEvent against /api/hooks/web)
  covering all 8 in-scope AgentEvent kinds, observed via Playwright against a
  real browser -- no VS Code, no mocked CLI.

- Dev ergonomics: scripts/web-agent.sh (reads port/token from
  ~/.pixel-agents/server.json, wraps the curl call) and
  server/manual-web-events.http (REST-Client sample mirroring the existing
  Claude one).

414/414 server tests, 52/52 webview tests pass. lint/check-types/build clean.
No AsyncAPI drift (no new message types). e2e/README.md regenerated for the
new standalone spec.
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