Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion node/agent_sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ export type {
ServerInfo,
SteerInput,
SetPlanModeResult,
// Hooks (Wire 1.7)
HookSubscription,
HooksInfo,
HookTriggered,
HookResolved,
HookRequest,
} from "./schema";

// Schemas
Expand All @@ -133,11 +139,17 @@ export {
parseRequestPayload,
SteerInputSchema,
SetPlanModeResultSchema,
// Hooks (Wire 1.7)
HookSubscriptionSchema,
HooksInfoSchema,
HookTriggeredSchema,
HookResolvedSchema,
HookRequestSchema,
} from "./schema";

// Protocol
export { ProtocolClient } from "./protocol";
export type { PromptStream, ReplayStream } from "./protocol";
export type { PromptStream, ReplayStream, HookHandler, HookRegistration } from "./protocol";

// Logging
export { enableLogs, disableLogs, setLogSink } from "./logger";
85 changes: 81 additions & 4 deletions node/agent_sdk/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import {
type ExternalTool,
type ToolCallRequest,
type ToolReturnValue,
type HookRequest,
type HookSubscription,
SetPlanModeResultSchema,
type SetPlanModeResult,
} from "./schema";
import { TransportError, ProtocolError, CliError } from "./errors";
import { log } from "./logger";

const PROTOCOL_VERSION = "1.5";
const PROTOCOL_VERSION = "1.7";
const SDK_NAME = "kimi-agent-sdk";

declare const __SDK_VERSION__: string;
Expand All @@ -33,6 +35,23 @@ export interface ClientInfo {
version: string;
}

/** Handler for a single wire hook — called when the matching subscription fires */
export type HookHandler = (request: HookRequest) => Promise<{ action: "allow" | "block"; reason?: string }>;

/** Hook registration: subscription config + handler */
export interface HookRegistration {
/** Unique ID for this subscription */
id: string;
/** Which lifecycle event to subscribe to */
event: string;
/** Regex filter. Empty matches everything */
matcher?: string;
/** Timeout in seconds */
timeout?: number;
/** Handler called when this hook fires */
handler: HookHandler;
}

export interface ClientOptions {
sessionId?: string;
workDir: string;
Expand All @@ -45,6 +64,8 @@ export interface ClientOptions {
agentFile?: string;
clientInfo?: ClientInfo;
skillsDir?: string;
/** Hook registrations — each binds a subscription to a handler (Wire 1.7) */
hooks?: HookRegistration[];
}

// Prompt Stream
Expand Down Expand Up @@ -118,6 +139,7 @@ export class ProtocolClient {
private pushEvent: ((event: StreamEvent) => void) | null = null;
private finishEvents: (() => void) | null = null;
private externalToolHandlers = new Map<string, ExternalTool["handler"]>();
private hookHandlers = new Map<string, HookHandler>();

get isRunning(): boolean {
return this.process !== null && this.process.exitCode === null;
Expand Down Expand Up @@ -168,8 +190,14 @@ export class ProtocolClient {
}
}

// Register hook handlers
const hookSubscriptions: HookSubscription[] | undefined = options.hooks?.map((h) => {
this.hookHandlers.set(h.id, h.handler);
return { id: h.id, event: h.event, matcher: h.matcher ?? "", timeout: h.timeout ?? 30 };
});

// Send initialize request
const initResult = await this.sendInitialize(options.externalTools, options.clientInfo);
const initResult = await this.sendInitialize(options.externalTools, options.clientInfo, hookSubscriptions);
return initResult;
}

Expand Down Expand Up @@ -289,7 +317,7 @@ export class ProtocolClient {
return this.sendRequest("steer", { user_input: content }).then(() => {});
}

private async sendInitialize(externalTools?: ExternalTool[], clientInfo?: ClientInfo): Promise<InitializeResult> {
private async sendInitialize(externalTools?: ExternalTool[], clientInfo?: ClientInfo, hooks?: HookSubscription[]): Promise<InitializeResult> {
let clientName = `${SDK_NAME}/${SDK_VERSION}`;
if (clientInfo?.name && clientInfo?.version) {
clientName += ` ${clientInfo.name}/${clientInfo.version}`;
Expand All @@ -315,6 +343,15 @@ export class ProtocolClient {
}));
}

if (hooks && hooks.length > 0) {
params.hooks = hooks.map((h) => ({
id: h.id,
event: h.event,
matcher: h.matcher ?? "",
timeout: h.timeout ?? 30,
}));
}

log.protocol("Sending initialize request: %O", params);
const result = await this.sendRequest("initialize", params);
const parsed = InitializeResultSchema.safeParse(result);
Expand Down Expand Up @@ -453,7 +490,12 @@ export class ProtocolClient {
return;
}

// For other request types (ApprovalRequest), emit as event
if (p.type === "HookRequest") {
this.handleHookRequest(requestId, p.payload as HookRequest);
return;
}

// For other request types (ApprovalRequest, QuestionRequest), emit as event
const result = parseRequestPayload(p.type, p.payload);
if (result.ok) {
this.pushEvent?.(result.value);
Expand Down Expand Up @@ -505,6 +547,41 @@ export class ProtocolClient {
});
}

private async handleHookRequest(requestId: string, request: HookRequest): Promise<void> {
let action: "allow" | "block" = "allow";
let reason = "";

// Dispatch by subscription_id to the registered handler
const handler = this.hookHandlers.get(request.subscription_id);
if (handler) {
try {
const result = await handler(request);
action = result.action;
reason = result.reason ?? "";
} catch (err) {
log.protocol("Hook handler error for subscription %s: %O", request.subscription_id, err);
// Fail-open: allow on handler error
action = "allow";
}
} else {
// No handler for this subscription — emit as event so Turn iterator can see it
const parsed = parseRequestPayload("HookRequest", request);
if (parsed.ok) {
this.pushEvent?.(parsed.value);
}
}

this.writeLine({
jsonrpc: "2.0",
id: requestId,
result: {
request_id: request.id,
action,
reason,
},
});
}

private emitParseError(code: string, message: string, raw?: string): void {
const error: ParseError = { type: "error", code, message, raw: raw?.slice(0, 500) };
this.pushEvent?.(error);
Expand Down
79 changes: 78 additions & 1 deletion node/agent_sdk/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,34 @@ export const ServerCapabilitiesSchema = z.object({
});
export type ServerCapabilities = z.infer<typeof ServerCapabilitiesSchema>;

// Hook subscription (Wire 1.7) — client subscribes to hook events
export const HookSubscriptionSchema = z.object({
/** Unique subscription ID — referenced in HookRequest to route to the right handler */
id: z.string(),
/** Which lifecycle event to subscribe to, e.g. 'PreToolUse', 'Stop' */
event: z.string(),
/** Regex filter. Empty matches everything */
matcher: z.string().optional().default(""),
/** Seconds to wait for client response before fail-open */
timeout: z.number().optional().default(30),
});
export type HookSubscription = z.infer<typeof HookSubscriptionSchema>;

// Hooks info returned in initialize result (Wire 1.7)
export const HooksInfoSchema = z.object({
/** All hook event types the server supports */
supported_events: z.array(z.string()),
/** Event -> number of configured hooks (server + wire) */
configured: z.record(z.string(), z.number()),
});
export type HooksInfo = z.infer<typeof HooksInfoSchema>;

export const InitializeParamsSchema = z.object({
protocol_version: z.string(),
client: ClientInfoSchema.optional(),
external_tools: z.array(ExternalToolDefinitionSchema).optional(),
/** Hook event subscriptions — server sends HookRequest when these fire (Wire 1.7) */
hooks: z.array(HookSubscriptionSchema).optional(),
capabilities: ClientCapabilitiesSchema.optional(),
});
export type InitializeParams = z.infer<typeof InitializeParamsSchema>;
Expand All @@ -290,6 +314,8 @@ export const InitializeResultSchema = z.object({
server: ServerInfoSchema,
slash_commands: z.array(SlashCommandInfoSchema),
external_tools: ExternalToolsResultSchema.optional(),
/** Hooks metadata — supported events and configured counts (Wire 1.7) */
hooks: HooksInfoSchema.optional(),
capabilities: ServerCapabilitiesSchema.optional(),
});
export type InitializeResult = z.infer<typeof InitializeResultSchema>;
Expand Down Expand Up @@ -412,6 +438,51 @@ export const StatusUpdateSchema = z.object({
});
export type StatusUpdate = z.infer<typeof StatusUpdateSchema>;

// ============================================================================
// Hook Events & Requests (Wire 1.7)
// ============================================================================

/** Fired when matched hooks start executing */
export const HookTriggeredSchema = z.object({
/** Hook event type, e.g. 'PreToolUse', 'Stop' */
event: z.string(),
/** What triggered the hook: tool name, agent name, etc. */
target: z.string().default(""),
/** Number of matched hooks running in parallel */
hook_count: z.number().default(1),
});
export type HookTriggered = z.infer<typeof HookTriggeredSchema>;

/** Fired when hook execution finishes */
export const HookResolvedSchema = z.object({
/** Hook event type */
event: z.string(),
/** Same as HookTriggered.target */
target: z.string().default(""),
/** Aggregate decision: 'block' if any hook blocked, 'allow' otherwise */
action: z.enum(["allow", "block"]).default("allow"),
/** Reason for blocking. Empty if allowed */
reason: z.string().default(""),
/** Wall-clock time for the batch, in milliseconds */
duration_ms: z.number().default(0),
});
export type HookResolved = z.infer<typeof HookResolvedSchema>;

/** Server requests client to handle a hook event (wire-subscribed hooks) */
export const HookRequestSchema = z.object({
/** Unique request ID */
id: z.string(),
/** Which subscription triggered this request */
subscription_id: z.string(),
/** Hook event type */
event: z.string(),
/** What triggered the hook */
target: z.string().default(""),
/** Full event payload (same as what shell hooks get on stdin) */
input_data: z.record(z.string(), z.unknown()).default({}),
});
export type HookRequest = z.infer<typeof HookRequestSchema>;

// Approval request payload
export const ApprovalRequestPayloadSchema = z.object({
// Request ID, referenced when responding
Expand Down Expand Up @@ -467,6 +538,8 @@ export type WireEvent =
| { type: "CompactionBegin"; payload: CompactionBegin }
| { type: "CompactionEnd"; payload: CompactionEnd }
| { type: "StatusUpdate"; payload: StatusUpdate }
| { type: "HookTriggered"; payload: HookTriggered }
| { type: "HookResolved"; payload: HookResolved }
| { type: "ContentPart"; payload: ContentPart }
| { type: "ToolCall"; payload: ToolCall }
| { type: "ToolCallPart"; payload: ToolCallPart }
Expand All @@ -479,7 +552,8 @@ export type WireEvent =
export type WireRequest =
| { type: "ApprovalRequest"; payload: ApprovalRequestPayload }
| { type: "ToolCallRequest"; payload: ToolCallRequest }
| { type: "QuestionRequest"; payload: QuestionRequest };
| { type: "QuestionRequest"; payload: QuestionRequest }
| { type: "HookRequest"; payload: HookRequest };

// Event type -> schema mapping
export const EventSchemas: Record<string, z.ZodSchema> = {
Expand All @@ -490,6 +564,8 @@ export const EventSchemas: Record<string, z.ZodSchema> = {
CompactionBegin: EmptyPayloadSchema,
CompactionEnd: EmptyPayloadSchema,
StatusUpdate: StatusUpdateSchema,
HookTriggered: HookTriggeredSchema,
HookResolved: HookResolvedSchema,
ContentPart: ContentPartSchema,
ToolCall: ToolCallSchema,
ToolCallPart: ToolCallPartSchema,
Expand All @@ -503,6 +579,7 @@ export const RequestSchemas: Record<string, z.ZodSchema> = {
ApprovalRequest: ApprovalRequestPayloadSchema,
ToolCallRequest: ToolCallRequestSchema,
QuestionRequest: QuestionRequestSchema,
HookRequest: HookRequestSchema,
};

type Result<T> = { ok: true; value: T } | { ok: false; error: string };
Expand Down
Loading