Files
openclaw/extensions/copilot/src/hooks-bridge.ts
Ramrajprabu f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00

135 lines
5.3 KiB
TypeScript
Executable File

/**
* Hooks bridge for the copilot agent runtime.
*
* BACK-POINTER: The host-side hook runner lives outside this package
* boundary in `src/agents/harness/lifecycle-hook-helpers.ts` (uses the
* plugin hook runner via `src/plugins/hook-runner-global.ts`). Per
* proposal §266 (todo `hooks-bridge`), this module provides a small
* contract surface that mirrors the SDK's `SessionHooks` shape; the
* core wiring layer constructs handlers that call into
* `runAgentHarnessLlmInputHook`, `runAgentHarnessLlmOutputHook`,
* `runAgentHarnessAgentEndHook`, etc., and threads them through
* `AttemptParamsLike.hooks`.
*
* Cross-package boundary note: the heavy host lifecycle helpers
* cannot be imported here (`tsconfig.package-boundary.base.json`). The
* bridge keeps the SDK hook contracts intact, wraps each provided
* handler in an error-isolating envelope so a thrown host hook cannot
* crash the SDK session, and returns a `SessionHooks` object that
* `createSessionConfig` can plug into `SessionConfig.hooks`.
*
* Note on default omission: if no handlers are supplied, the bridge
* returns `undefined` so that `SessionConfig.hooks` stays absent and
* the SDK skips the entire hook subsystem (matches the "no hooks
* installed" runtime behaviour the harness had pre-bridge).
*/
import type { SessionConfig } from "@github/copilot-sdk";
// All hook handler types are derived from SessionHooks so this bridge
// stays pinned to the same SDK source the rest of the harness uses,
// without depending on the SDK re-exporting individual handler aliases
// (which it does not, as of @github/copilot-sdk@1.0.0-beta.4).
type SdkSessionHooks = NonNullable<SessionConfig["hooks"]>;
type PreToolUseHandler = NonNullable<SdkSessionHooks["onPreToolUse"]>;
type PostToolUseHandler = NonNullable<SdkSessionHooks["onPostToolUse"]>;
type UserPromptSubmittedHandler = NonNullable<SdkSessionHooks["onUserPromptSubmitted"]>;
type SessionStartHandler = NonNullable<SdkSessionHooks["onSessionStart"]>;
type SessionEndHandler = NonNullable<SdkSessionHooks["onSessionEnd"]>;
type ErrorOccurredHandler = NonNullable<SdkSessionHooks["onErrorOccurred"]>;
export interface CopilotHooksConfig {
onPreToolUse?: PreToolUseHandler;
onPostToolUse?: PostToolUseHandler;
onUserPromptSubmitted?: UserPromptSubmittedHandler;
onSessionStart?: SessionStartHandler;
onSessionEnd?: SessionEndHandler;
onErrorOccurred?: ErrorOccurredHandler;
/**
* Optional hook-error notifier. Called whenever any wrapped handler
* throws (synchronously or as a Promise rejection). Defaults to
* `console.warn` so the failure is visible to operators without
* crashing the SDK session. Receives the SDK hook name and the
* raised error.
*/
onHookError?: (info: { hookName: keyof SdkSessionHooks; error: unknown }) => void;
}
const DEFAULT_HOOK_ERROR_HANDLER: NonNullable<CopilotHooksConfig["onHookError"]> = ({
hookName,
error,
}) => {
console.warn(`[copilot hooks-bridge] ${hookName} handler threw:`, error);
};
/**
* Wrap a host handler in an error-isolating envelope so it cannot
* throw out into the SDK. Returns `undefined` (no opinion) when the
* host handler throws, so the SDK falls back to its default behaviour
* for that hook.
*/
function isolate<TArgs extends readonly unknown[], TResult>(
hookName: keyof SdkSessionHooks,
handler: ((...args: TArgs) => TResult | Promise<TResult>) | undefined,
onError: NonNullable<CopilotHooksConfig["onHookError"]>,
): ((...args: TArgs) => Promise<TResult | undefined>) | undefined {
if (!handler) {
return undefined;
}
return async (...args: TArgs) => {
try {
return await handler(...args);
} catch (error) {
try {
onError({ hookName, error });
} catch {
// never let the error notifier itself throw out
}
return undefined;
}
};
}
/**
* Build an SDK-shaped `SessionHooks` object from a host-supplied
* `CopilotHooksConfig`. Returns `undefined` when no handlers were
* supplied so the SDK skips the hook subsystem entirely.
*/
export function createHooksBridge(config?: CopilotHooksConfig): SdkSessionHooks | undefined {
if (!config) {
return undefined;
}
const onError = config.onHookError ?? DEFAULT_HOOK_ERROR_HANDLER;
const hooks: SdkSessionHooks = {};
const pre = isolate("onPreToolUse", config.onPreToolUse, onError);
const post = isolate("onPostToolUse", config.onPostToolUse, onError);
const userPrompt = isolate("onUserPromptSubmitted", config.onUserPromptSubmitted, onError);
const sessionStart = isolate("onSessionStart", config.onSessionStart, onError);
const sessionEnd = isolate("onSessionEnd", config.onSessionEnd, onError);
const errorOccurred = isolate("onErrorOccurred", config.onErrorOccurred, onError);
if (pre) {
hooks.onPreToolUse = pre as PreToolUseHandler;
}
if (post) {
hooks.onPostToolUse = post as PostToolUseHandler;
}
if (userPrompt) {
hooks.onUserPromptSubmitted = userPrompt as UserPromptSubmittedHandler;
}
if (sessionStart) {
hooks.onSessionStart = sessionStart as SessionStartHandler;
}
if (sessionEnd) {
hooks.onSessionEnd = sessionEnd as SessionEndHandler;
}
if (errorOccurred) {
hooks.onErrorOccurred = errorOccurred as ErrorOccurredHandler;
}
if (Object.keys(hooks).length === 0) {
return undefined;
}
return hooks;
}