Files
orca/src/shared/agent-hook-relay.ts
T
Neil 90801e2deb feat(agents): add first-class ZCode harness (#22464)
* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
2026-09-25 02:17:51 -07:00

242 lines
10 KiB
TypeScript

// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification
// the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which
// produces it after the shared listener parses an HTTP POST) and by
// `src/main/agent-hooks/server.ts` (which ingests it via `ingestRemote`).
//
// Lives in `shared/` because the relay deliberately has no Electron dependency
// (cf. `src/relay/protocol.ts` header). `agent-hook-types.ts` is reserved for
// the renderer-bound IPC + installer contract; this module is the wire envelope
// between Orca's main process and the remote relay.
//
// Invariants both ends rely on:
// - The relay normalizes; Orca routes. The envelope's `payload` field has
// already been through `normalizeHookPayload` (which calls
// `parseAgentStatusPayload` → `normalizeAgentStatusObject`) on the relay
// side. Orca's `ingestRemote` re-runs the canonical payload normalizer at
// the SSH trust boundary before caching or persisting, so relay skew or a
// buggy remote process cannot poison main-process state.
// - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's
// local handle on an `ssh2` connection, not a wire identity. Orca stamps the
// real value on receive from `mux` identity inside `ingestRemote`.
// - The wire `version` and `env` fields are forwarded from the agent CLI's
// POST body so Orca's warn-once protocol diagnostics still fire. The relay
// default env is `remote`, a location marker ignored by dev-vs-prod checks.
import { createHash } from 'node:crypto'
import type { AgentSubagentSnapshot, ParsedAgentStatusPayload } from './agent-status-types'
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentHookTarget } from './agent-hook-types'
// Why: the local hook server knows the discriminator from URL pathname routing
// (`/hook/<source>`); the relay equally must tag each forwarded notification
// with the same value so Orca can attribute the event back to the right CLI.
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
// without dragging Electron in (the shared listener module is the only place
// that consumes it from the relay side).
const AGENT_HOOK_SOURCES = [
'claude',
'codex',
'gemini',
'antigravity',
'amp',
'opencode',
'opencode2',
'mimo-code',
'cursor',
'pi',
'omp',
'prime-agent',
'droid',
'command-code',
'grok',
'copilot',
'hermes',
'devin',
'kimi',
'muse',
'zcode'
] as const
export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
const AGENT_HOOK_SOURCE_SET: ReadonlySet<string> = new Set(AGENT_HOOK_SOURCES)
export function isAgentHookSource(value: unknown): value is AgentHookSource {
return typeof value === 'string' && AGENT_HOOK_SOURCE_SET.has(value)
}
/** 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
/** Wire envelope for a single hook event flowing relay → Orca. */
export type AgentHookRelayEnvelope = {
source: AgentHookSource
paneKey: string
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
launchToken?: string
tabId?: string
worktreeId?: string
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
connectionId: null
/** Preserved from the relay-side normalized hook event so Orca can
* distinguish a true same-prompt retry from a cached-prompt tool ping. */
hasExplicitPrompt?: boolean
/** Optional stable per-turn key from the relay-side listener. Used only for
* in-memory dedupe; never included in product telemetry payloads. */
promptInteractionKey?: string
/** Hook discriminator preserved for main-process transition rules. */
hookEventName?: string
/** Provider-owned turn identity (Claude UUID or opaque Grok prompt id). */
providerPromptId?: string
/** The row belongs to an observed Grok prompt boundary whose opaque id may be absent. */
grokPromptBoundary?: true
/** Active Claude compact generation, keyed by provider prompt identity. */
compactTrigger?: 'manual' | 'auto'
/** Claude tool execution id, when the source hook provides one. */
toolUseId?: string
/** Claude subagent identity, when the source hook provides one. */
toolAgentId?: string
/** Claude teammate name carried by TeammateIdle. */
teammateName?: string
/** Claude agent type, used only as a lower-confidence identity fallback. */
toolAgentType?: string
/** Provider-owned conversation/session id needed to resume a sleeping agent. */
providerSession?: AgentProviderSessionMetadata
/** True when this envelope updates resume identity without changing turn status. */
providerSessionOnly?: boolean
/** True when the relay is replaying its cache after Orca reconnects. */
isReplay?: boolean
/** Claude background-work evidence for input-interrupt inference on the receiving host. */
claudeRunningNonAgentTask?: boolean
/** Forwarded from the agent CLI POST body. The relay default is `remote`,
* which marks transport location rather than dev/prod build env. */
env?: string
/** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once
* protocol-version diagnostic fire on remote events the same as on local. */
version?: string
/** Pre-normalized status payload from the relay's `normalizeHookPayload`.
* Orca's `ingestRemote` validates it again at the SSH trust boundary. */
payload: ParsedAgentStatusPayload
}
/** JSON-RPC notification method name carried over the relay control channel. */
export const AGENT_HOOK_NOTIFICATION_METHOD = 'agent.hook' as const
/** Identifies optional payload fields the relay dropped to fit an oversized frame
* (see `src/relay/agent-hook-envelope-publication.ts`), so `ingestRemote` can tell
* "shed in transit" from "the agent cleared it"; rosters include their digest. */
export const AGENT_HOOK_SHED_FIELDS_KEY = 'shedFields' as const
const AGENT_HOOK_SHED_SUBAGENTS_DIGEST_PREFIX = 'subagents:sha256:'
function subagentRosterDigest(subagents: readonly AgentSubagentSnapshot[]): string {
const stableRoster = subagents.map(({ id, state, startedAt, agentType, model, description }) => [
id,
state,
startedAt,
agentType ?? null,
model ?? null,
description ?? null
])
return createHash('sha256').update(JSON.stringify(stableRoster)).digest('base64url')
}
/** Carries the dropped roster identity without carrying its full rows. */
export function createShedSubagentsField(subagents: readonly AgentSubagentSnapshot[]): string {
return `${AGENT_HOOK_SHED_SUBAGENTS_DIGEST_PREFIX}${subagentRosterDigest(subagents)}`
}
function hasMatchingShedSubagentsField(
shedFields: readonly unknown[],
previous: ParsedAgentStatusPayload
): boolean {
if (!previous.subagents) {
return false
}
const expected = createShedSubagentsField(previous.subagents)
return shedFields.some((field) => field === expected)
}
function hasMatchingTurnIdentity(
payload: ParsedAgentStatusPayload,
previous: ParsedAgentStatusPayload
): boolean {
return (
payload.prompt === previous.prompt &&
payload.agentType === previous.agentType &&
payload.model === previous.model
)
}
/**
* Re-attaches shed fields from Orca's cached payload for this pane, so a transport-level
* truncation cannot read as a cleared field — an absent `subagents` blanks live child rows and
* unblocks hibernation for a pane whose teammates are still running.
*
* `interactivePrompt` and `lastAssistantMessage` are deliberately not restored: cached prose can
* belong to an earlier turn. A roster returns only when its wire digest and turn identity match.
*/
export function restoreShedStatusFields(
payload: ParsedAgentStatusPayload,
shedFields: unknown,
previous: ParsedAgentStatusPayload | undefined
): ParsedAgentStatusPayload {
if (!previous || !Array.isArray(shedFields) || shedFields.length === 0) {
return payload
}
const subagents =
payload.subagents === undefined &&
hasMatchingTurnIdentity(payload, previous) &&
hasMatchingShedSubagentsField(shedFields, previous)
? previous.subagents
: undefined
if (subagents === undefined) {
return payload
}
return {
...payload,
subagents
}
}
/** JSON-RPC request method Orca issues after `--connect` reattach to ask the
* relay to replay its per-paneKey last-payload cache. Pull, not push: a relay
* that pushed on `setWrite` can emit before Orca has wired its `agent.hook`
* handler, and those notifications are dropped silently. */
export const AGENT_HOOK_REQUEST_REPLAY_METHOD = 'agent_hook.requestReplay' as const
/** JSON-RPC request method Orca issues at session-ready to ship the
* OpenCode/Pi plugin source files to the relay so it can materialize the
* overlay dirs on the remote. */
export const AGENT_HOOK_INSTALL_PLUGINS_METHOD = 'agent_hook.installPlugins' as const
/** JSON-RPC request method that asks the remote relay to install every
* managed hook using its local filesystem instead of WAN-bound SFTP. */
export const AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD = 'agent_hook.installManagedHooks' as const
export type AgentHookInstallManagedHooksParams = {
/** SHA-256 fingerprint of the server key negotiated by Orca's SSH transport. */
hostKeyFingerprint?: string
/** Positively detected and enabled agents allowed to mutate remote config. */
agents: readonly AgentHookTarget[]
/** Execution-host Claude version; absent means retain the legacy hook set. */
claudeVersion?: string
}
/** Feature-flag env var. Read once at process start by Orca and the relay.
* Remote agent hooks ship as the default SSH behavior; set "0" to opt out. */
export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const
export function isRemoteAgentHooksEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]
if (raw === undefined) {
return true
}
const trimmed = raw.trim()
if (trimmed.length === 0 || trimmed === '0') {
return false
}
return true
}