mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
* fix(notifications): tie a completion banner to the hook turn it announces, not a 10 s window A title-only working->idle edge (Claude's /btw side question fires no hook) raised a generic "Task complete" banner because the pane's hook row was older than 10 s. On a hook-reporting pane the hook row now decides: a row not yet announced supplies the banner content however old it is; an already-announced row means no turn ended, so no banner. Hookless agents keep the generic title fallback. * fix(notifications): a pane with hooks gets its completion banner from its hook turn only The title lanes (mounted pane and parked watcher) announced a finished task on every working->idle title edge, even on panes whose agent reports turns through hooks. A title spin that ends no hook turn (Claude's /btw side question, Codex's /compact) raised an extra banner that could only be filled with the workspace name and tab title. On a pane the hook lane is tracking, with a settled Done row from the same agent, the title lane now defers before it claims the turn, so the hook lane's banner (with the last message) is the only one. A BEL in the same burst waits on the hook lane's pending Done. Hookless agents, restored rows, hooks switched off and the post-restart re-arm keep the title banner. This replaces the previous commit's announcement memory in the dispatcher. * fix(notifications): hook-owned turn ends settle pane bookkeeping and announce re-entered Stops - A title idle edge that defers to the hook lane now settles the pane's turn, so quitting the agent after a /btw no longer raises a generic "Task complete" banner. - A BEL held while the hook lane's Done is pending is superseded by that Done at fire time instead of being held until the next turn start. - A Stop re-entered after a blocking Stop hook carries its new final message in its identity, so the hook lane announces it instead of deduping it against the first Stop. - Title lanes ask the hook lane whether it can announce the turn end (live, agent evidence, not re-arming) instead of copying its conditions, and fall back when the agent's own hooks are switched off. - The hook lane's non-quiet Done forwards its own snapshot, so its banner content no longer depends on the stored row's age. - The whole-flow test now drives the real pane wiring, the real hook lane and the real dispatcher. * fix(notifications): the host marks each real turn end, so a repeated status can't raise a second banner The renderer told a Stop re-entered after a blocking Stop hook from a restatement by its final message. The message also changes when the listener's cache resets (a session reset, then an idle reminder), so a restated finished turn raised a second, empty "finished" banner. The execution host now classifies each provider's own turn-end event by name and stamps an optional `turnEndedAt` on the status row. Idle reminders, session events, replays, child re-emits and result retries restate it; a turn-end event on an already finished row is a new end only when the agent says it re-entered (`stop_hook_active`). Local hosts and SSH hosts (orcad) mint it in `attachStatusTiming`; the WSL relay mints it at admission and sends it on its envelope. The field is persisted, forwarded over IPC and the paired-server session projection, and the renderer's done identity is `turnEndedAt ?? stateStartedAt` (after the existing `turnCompletedAt` precedence). `stateStartedAt` and every other clock are unchanged. Paired servers that predate the stamp advertise no `agent-status.turn-ended-at.v1` capability; their panes keep the terminal-title completion fallback exactly as before. * test(notifications): type the old-server pane setup in the completion flow test * fix(notifications): a blocking Stop's final banner reaches the user; /compact and quitting an idle agent stay quiet Real-agent QA on the hook-only completion design found three gaps: - A Stop re-entered after a blocking Stop hook could be dropped twice over: inside 1 s of the first banner by the replay guard (a new host turn-end stamp is now a real end), and by main's 5 s per-workspace desktop cooldown. A banner with the same notification id and changed text now replaces the one already shown instead of being dropped; every other cooldown rule is unchanged. - On a mounted pane, a hook-owned idle edge (Codex /compact) no longer claimed a completion, so the burst's BEL went out as a Bell banner. The deferred idle edge now absorbs that BEL like a claimed completion did. - Quitting an idle agent after a title spin raised a generic process-exit banner. On a pane whose hook lane owns turn ends and whose last turn is done, an exit ends no turn; a crash mid-turn still gets its banner. * test(runtime): the projection reference keys on the host turn-end stamp too * test(relay): restore the Copilot retry test while CI isolates a runner error * test: key the hot-path projection reference on the turn-end stamp; relay retry restates the end * fix(notifications): host turn identity per agent session; keep later blocking-Stop banners past main's cooldown - The execution host (local main and the WSL relay, one shared reducer) now tracks which turn an agent session is in: Codex by its own turn id, Command Code by its transcript prompt position, every other agent by its prompt-opening hook. A turn end mints a new end when its turn differs from the last ended one, so a Codex turn woken by agent mail and a text-only Command Code turn get their banner; duplicates, replays, tool reopens and result retries restate it. The record is saved with the status row (validated on restore) and never published. - Main's desktop and mobile notification cooldowns are back to main's code exactly. - A later end in the same done segment (a Stop re-entered after a blocking Stop hook) that lands inside main's cooldown is held, latest only, and sent once after that cooldown through main's unchanged gates; the renderer learns main's answer to anchor it. - One pane check gates every changed pane behavior: the pane's bound PTY runs on a host that stamps turn ends and its agent's continuation signal is verified (Claude, Codex). Every other pane, and an unbound PTY, keeps main's behavior. - A hook-owned idle edge absorbs its burst's BEL for main's full 1.5 s; exits and replacements ask the owner check with the exiting agent; a title spin on a hook-owned pane opens no turn. * test(notifications): run the whole-pane harness through main's shared desktop cooldown, not main's service module * fix(notifications): decide hook ownership from how the host identified each turn end The execution host now labels every classified turn end with one optional disposition beside its end stamp, by the same rule for every agent: identified (an unused prompt opening, a new native turn id, or a verified re-entered Stop) mints a new end; a same-turn repeat is a proven duplicate only when the agent supplies both a turn id and a verified continuation flag; anything else is ambiguous and never mints. Replays and retries restate the decided answer, and local and relay hosts carry an end only within a done segment. The renderer replaces the Claude/Codex pane gate with one evidence decision: identified ends are hook-owned, ambiguous ends keep the title lane's role, and rows without a disposition (older hosts, unclassified agents) keep the previous behavior exactly. A title working edge on a hook-owned pane keeps its turn bookkeeping and only the hook receipt; an owned idle edge settles after the existing max wait, or announces through the title path if the end turned out ambiguous. Idle and working title edges resolve the same owner (the title's agent, else the foreground one). The held later banner for identified continuations is unchanged. * test(notifications): give the whole-pane harness's host event its local connection id * fix(notifications): a settled cancellation ends the open turn, so its late Stop folds as on main A cancel Orca inferred from Esc or Ctrl+C writes a done row with no turn-end event. The host reducer now completes the current turn key on that settled cancellation, so the same turn's late Stop is a repeat (ambiguous, no new stamp) instead of a second identified end that requested a second banner. * fix(notifications): a settled cancellation ends the open turn on every lane, so a relay's late cancel end restates none The desktop restates a WSL relay's end as decided. The relay never learns a cancel the desktop inferred for a non-Claude agent, so Grok's own late stop_cancelled reached the desktop as an identified end and gave a second banner. One predicate now decides both lanes: after a settled cancellation that carried no end, a restated end keeps the shown one, and a child event carrying the cancellation also ends the open turn. The relay's own cancel row is the main agent's, never a child's. * fix(notifications): a relayed done segment shown without its own end keeps showing none The restatement fold tested the previous row's cancel verdict, so the row it left behind (a late plain stop) no longer matched and the relay's next restatement brought its end back. Fold on the row's turn-end fact instead, which the folded row satisfies again.
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
import type { AgentProcessPresence } from './agent-process-presence'
|
|
import type { ClaudeSettledRequest } from './agent-hook-listener/claude-settled-permission-requests'
|
|
// Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification
|
|
// the relay sends to Orca. Consumed by `src/wsl-guest/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/wsl-guest/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,
|
|
TurnEndDisposition
|
|
} 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',
|
|
'qoder',
|
|
'qoder-cn',
|
|
'qwen-code',
|
|
'codebuddy',
|
|
'gemini',
|
|
'antigravity',
|
|
'amp',
|
|
'opencode',
|
|
'opencode2',
|
|
'mimo-code',
|
|
'cursor',
|
|
'pi',
|
|
'omp',
|
|
'prime-agent',
|
|
'droid',
|
|
'command-code',
|
|
'grok',
|
|
'copilot',
|
|
'hermes',
|
|
'devin',
|
|
'kimi',
|
|
'muse',
|
|
'zcode',
|
|
'dsh',
|
|
'jcode',
|
|
'kiro'
|
|
] 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
|
|
agentPresence?: AgentProcessPresence
|
|
/** Ephemeral Orca launch identity stamped into the PTY env for this process. */
|
|
launchToken?: string
|
|
/** Optional support proof and exact host turn fence for explicit interrupt reconciliation. */
|
|
hostTurnRevision?: 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
|
|
/** Optional, replay only: Claude requests the relay saw end while this receiver may have been away. */
|
|
claudeSettledRequests?: readonly ClaudeSettledRequest[]
|
|
/** 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
|
|
/** The relay's turn-end stamp (see AgentHookEventPayload.turnEndedAt); older relays omit it. */
|
|
turnEndedAt?: number
|
|
/** How the relay identified that end; older relays omit it (legacy). */
|
|
turnEndDisposition?: TurnEndDisposition
|
|
/** Claude live work the child list does not show (a shell, a cron, an owed task notification), 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. */
|
|
evidenceAgeMs?: number
|
|
payload: ParsedAgentStatusPayload
|
|
}
|
|
|
|
/** Older clients ignore the null payload; newer clients clear only the selected projection. */
|
|
export type AgentHookUnavailableEnvelope = {
|
|
source: 'opencode' | 'opencode2'
|
|
paneKey: string
|
|
tabId?: string
|
|
worktreeId?: string
|
|
launchToken?: string
|
|
connectionId: null
|
|
statusUnavailable: true
|
|
payload: null
|
|
}
|
|
|
|
/** 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/wsl-guest/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
|
|
}
|
|
|
|
/** Opt-out env var for the Windows WSL agent-hook relay, read on each relay start; set "0" to
|
|
* disable. */
|
|
export const ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV = 'ORCA_FEATURE_REMOTE_AGENT_HOOKS' as const
|
|
|
|
export function isWslAgentHookRelayEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
const raw = env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV]
|
|
return raw === undefined || !['', '0'].includes(raw.trim())
|
|
}
|