agenthooksprotocol 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +28 -2
- package/dist/src/client/auth.d.ts +147 -0
- package/dist/src/client/auth.d.ts.map +1 -0
- package/dist/src/client/auth.js +629 -0
- package/dist/src/client/auth.js.map +1 -0
- package/dist/src/client/composition.d.ts +42 -0
- package/dist/src/client/composition.d.ts.map +1 -0
- package/dist/src/client/composition.js +523 -0
- package/dist/src/client/composition.js.map +1 -0
- package/dist/src/client/content.d.ts +73 -0
- package/dist/src/client/content.d.ts.map +1 -0
- package/dist/src/client/content.js +404 -0
- package/dist/src/client/content.js.map +1 -0
- package/dist/src/client/hooks.d.ts +62 -0
- package/dist/src/client/hooks.d.ts.map +1 -0
- package/dist/src/client/hooks.js +997 -0
- package/dist/src/client/hooks.js.map +1 -0
- package/dist/src/client/index.d.ts +11 -0
- package/dist/src/client/index.d.ts.map +1 -0
- package/dist/src/client/index.js +9 -0
- package/dist/src/client/index.js.map +1 -0
- package/dist/src/client/transport.d.ts +12 -0
- package/dist/src/client/transport.d.ts.map +1 -0
- package/dist/src/client/transport.js +347 -0
- package/dist/src/client/transport.js.map +1 -0
- package/dist/src/client/types.d.ts +117 -0
- package/dist/src/client/types.d.ts.map +1 -0
- package/dist/src/client/types.js +10 -0
- package/dist/src/client/types.js.map +1 -0
- package/dist/src/client/validation.d.ts +3 -0
- package/dist/src/client/validation.d.ts.map +1 -0
- package/dist/src/client/validation.js +25 -0
- package/dist/src/client/validation.js.map +1 -0
- package/dist/src/compaction.d.ts +27 -0
- package/dist/src/compaction.d.ts.map +1 -0
- package/dist/src/compaction.js +155 -0
- package/dist/src/compaction.js.map +1 -0
- package/dist/src/content-upload.d.ts +60 -0
- package/dist/src/content-upload.d.ts.map +1 -0
- package/dist/src/content-upload.js +129 -0
- package/dist/src/content-upload.js.map +1 -0
- package/dist/src/draft/generated.d.ts +3085 -0
- package/dist/src/draft/generated.d.ts.map +1 -0
- package/dist/src/draft/generated.js +15372 -0
- package/dist/src/draft/generated.js.map +1 -0
- package/dist/src/draft/index.d.ts +31 -0
- package/dist/src/draft/index.d.ts.map +1 -0
- package/dist/src/draft/index.js +129 -0
- package/dist/src/draft/index.js.map +1 -0
- package/dist/src/draft/schemas.d.ts +8848 -0
- package/dist/src/draft/schemas.d.ts.map +1 -0
- package/dist/src/draft/schemas.js +9219 -0
- package/dist/src/draft/schemas.js.map +1 -0
- package/dist/src/draft-atomic.d.ts +18 -0
- package/dist/src/draft-atomic.d.ts.map +1 -0
- package/dist/src/draft-atomic.js +102 -0
- package/dist/src/draft-atomic.js.map +1 -0
- package/dist/src/draft-runtime.d.ts +89 -0
- package/dist/src/draft-runtime.d.ts.map +1 -0
- package/dist/src/draft-runtime.js +242 -0
- package/dist/src/draft-runtime.js.map +1 -0
- package/dist/src/elicitation.d.ts +18 -0
- package/dist/src/elicitation.d.ts.map +1 -0
- package/dist/src/elicitation.js +191 -0
- package/dist/src/elicitation.js.map +1 -0
- package/dist/src/errors.d.ts +7 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +15 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/framing.d.ts +7 -0
- package/dist/src/framing.d.ts.map +1 -0
- package/dist/src/framing.js +39 -0
- package/dist/src/framing.js.map +1 -0
- package/dist/src/generated.d.ts +304 -0
- package/dist/src/generated.d.ts.map +1 -0
- package/dist/src/generated.js +1885 -0
- package/dist/src/generated.js.map +1 -0
- package/dist/src/hook-runner.d.ts +11 -0
- package/dist/src/hook-runner.d.ts.map +1 -0
- package/dist/src/hook-runner.js +223 -0
- package/dist/src/hook-runner.js.map +1 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +8 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/observation.d.ts +26 -0
- package/dist/src/observation.d.ts.map +1 -0
- package/dist/src/observation.js +28 -0
- package/dist/src/observation.js.map +1 -0
- package/dist/src/server/attachments.d.ts +29 -0
- package/dist/src/server/attachments.d.ts.map +1 -0
- package/dist/src/server/attachments.js +112 -0
- package/dist/src/server/attachments.js.map +1 -0
- package/dist/src/server/hooks.d.ts +20 -0
- package/dist/src/server/hooks.d.ts.map +1 -0
- package/dist/src/server/hooks.js +129 -0
- package/dist/src/server/hooks.js.map +1 -0
- package/dist/src/server/index.d.ts +7 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +4 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/stdio.d.ts +15 -0
- package/dist/src/server/stdio.d.ts.map +1 -0
- package/dist/src/server/stdio.js +69 -0
- package/dist/src/server/stdio.js.map +1 -0
- package/dist/src/transport.d.ts +7 -0
- package/dist/src/transport.d.ts.map +1 -0
- package/dist/src/transport.js +195 -0
- package/dist/src/transport.js.map +1 -0
- package/dist/src/types.d.ts +116 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +14 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/validation.d.ts +7 -0
- package/dist/src/validation.d.ts.map +1 -0
- package/dist/src/validation.js +209 -0
- package/dist/src/validation.js.map +1 -0
- package/package.json +62 -4
- package/src/client/auth.ts +1076 -0
- package/src/client/composition.ts +708 -0
- package/src/client/content.ts +510 -0
- package/src/client/hooks.ts +1368 -0
- package/src/client/index.ts +28 -0
- package/src/client/transport.ts +408 -0
- package/src/client/types.ts +173 -0
- package/src/client/validation.ts +24 -0
- package/src/compaction.ts +196 -0
- package/src/content-upload.ts +205 -0
- package/src/draft/README.md +16 -0
- package/src/draft/ahp-codegen.lock.json +123 -0
- package/src/draft/generated.ts +19143 -0
- package/src/draft/index.ts +246 -0
- package/src/draft/schemas.ts +9413 -0
- package/src/draft-atomic.ts +119 -0
- package/src/draft-runtime.ts +365 -0
- package/src/elicitation.ts +254 -0
- package/src/errors.ts +30 -0
- package/src/framing.ts +56 -0
- package/src/generated.ts +2408 -0
- package/src/hook-runner.ts +310 -0
- package/src/index.ts +8 -0
- package/src/observation.ts +65 -0
- package/src/server/attachments.ts +128 -0
- package/src/server/hooks.ts +205 -0
- package/src/server/index.ts +18 -0
- package/src/server/node-crypto.d.ts +7 -0
- package/src/server/stdio.ts +81 -0
- package/src/transport.ts +273 -0
- package/src/types.ts +124 -0
- package/src/validation.ts +372 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
validateCapabilitiesRequest,
|
|
3
|
+
validateCapabilitiesResponse,
|
|
4
|
+
validateInterceptRequest,
|
|
5
|
+
validateInterceptResponse,
|
|
6
|
+
} from "../draft/index.js";
|
|
7
|
+
import {
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
parseObserveNotification,
|
|
10
|
+
type CapabilitiesRequest,
|
|
11
|
+
type CapabilitiesResponse,
|
|
12
|
+
type InterceptRequest,
|
|
13
|
+
type InterceptResponse,
|
|
14
|
+
type ObserveNotification,
|
|
15
|
+
} from "../draft/generated.js";
|
|
16
|
+
import { validateWire } from "../client/validation.js";
|
|
17
|
+
|
|
18
|
+
/** A decoded canonical wire message, not a host execution boundary. */
|
|
19
|
+
export type Message =
|
|
20
|
+
| InterceptRequest
|
|
21
|
+
| ObserveNotification
|
|
22
|
+
| CapabilitiesRequest;
|
|
23
|
+
type ResultFields<T> = {
|
|
24
|
+
[K in keyof T as string extends K
|
|
25
|
+
? never
|
|
26
|
+
: K extends "protocolVersion"
|
|
27
|
+
? never
|
|
28
|
+
: K]: T[K];
|
|
29
|
+
};
|
|
30
|
+
export type InterceptResult = ResultFields<InterceptResponse["result"]>;
|
|
31
|
+
export type CapabilitiesResult = ResultFields<CapabilitiesResponse["result"]>;
|
|
32
|
+
export type Handler = (
|
|
33
|
+
message: Message,
|
|
34
|
+
) =>
|
|
35
|
+
| InterceptResult
|
|
36
|
+
| CapabilitiesResult
|
|
37
|
+
| void
|
|
38
|
+
| Promise<InterceptResult | CapabilitiesResult | void>;
|
|
39
|
+
|
|
40
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
function json(value: unknown, status = 200): Response {
|
|
44
|
+
return new Response(JSON.stringify(value), {
|
|
45
|
+
status,
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function error(
|
|
50
|
+
id: unknown,
|
|
51
|
+
code: number,
|
|
52
|
+
message: string,
|
|
53
|
+
notification: boolean,
|
|
54
|
+
status = 200,
|
|
55
|
+
): Response {
|
|
56
|
+
return notification
|
|
57
|
+
? new Response(null, { status: status === 200 ? 500 : status })
|
|
58
|
+
: json({ jsonrpc: "2.0", id, error: { code, message } }, status);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check support without executing, resolving bodies, or composing host state. */
|
|
62
|
+
function supported(
|
|
63
|
+
request: InterceptRequest,
|
|
64
|
+
response: InterceptResponse,
|
|
65
|
+
): boolean {
|
|
66
|
+
const caps = request.params.capabilities;
|
|
67
|
+
return response.result.effects.every((effect) => {
|
|
68
|
+
if (!caps.effects.includes(effect.type)) return false;
|
|
69
|
+
if (effect.type === "modify") {
|
|
70
|
+
const target = caps.modify?.[effect.target];
|
|
71
|
+
return object(target) && target[effect.operation] === true;
|
|
72
|
+
}
|
|
73
|
+
if (effect.type === "inject") {
|
|
74
|
+
return (
|
|
75
|
+
caps.inject?.context.append === true &&
|
|
76
|
+
caps.inject.context.deliverAt.includes(effect.deliverAt)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (effect.type === "flow") {
|
|
80
|
+
if (!caps.flow?.operations.includes(effect.operation)) return false;
|
|
81
|
+
if (effect.operation === "continue") {
|
|
82
|
+
const flow = caps.flow;
|
|
83
|
+
return (
|
|
84
|
+
typeof flow.remainingContinuations === "number" &&
|
|
85
|
+
flow.remainingContinuations > 0 &&
|
|
86
|
+
(flow.maxContinuations === undefined ||
|
|
87
|
+
(flow.continuationCount ?? 0) < flow.maxContinuations)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
effect.type === "return" &&
|
|
93
|
+
request.params.event.type === "user.elicitation.request"
|
|
94
|
+
) {
|
|
95
|
+
const mode = request.params.event.elicitation.mode;
|
|
96
|
+
if (!caps.elicitation?.[mode]) return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Handle one HTTP POST. The application owns authentication and routing.
|
|
103
|
+
* Observe callbacks return void. No notification receives a JSON-RPC reply.
|
|
104
|
+
* HTTP framing errors use 4xx; evaluated RPC failures use 200. Exceptions and
|
|
105
|
+
* invalid callback results are replaced by fixed, credential-free errors.
|
|
106
|
+
*/
|
|
107
|
+
async function handle(request: Request, handler: Handler): Promise<Response> {
|
|
108
|
+
if (request.method !== "POST")
|
|
109
|
+
return new Response(null, { status: 405, headers: { allow: "POST" } });
|
|
110
|
+
if (
|
|
111
|
+
request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !==
|
|
112
|
+
"application/json"
|
|
113
|
+
)
|
|
114
|
+
return new Response(null, { status: 415 });
|
|
115
|
+
let raw: unknown;
|
|
116
|
+
try {
|
|
117
|
+
raw = JSON.parse(await request.text()) as unknown;
|
|
118
|
+
} catch {
|
|
119
|
+
return error(null, -32700, "Parse error", false, 400);
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
!object(raw) ||
|
|
123
|
+
raw.jsonrpc !== "2.0" ||
|
|
124
|
+
typeof raw.method !== "string" ||
|
|
125
|
+
(Object.hasOwn(raw, "id") &&
|
|
126
|
+
typeof raw.id !== "string" &&
|
|
127
|
+
typeof raw.id !== "number" &&
|
|
128
|
+
raw.id !== null)
|
|
129
|
+
) {
|
|
130
|
+
return error(null, -32600, "Invalid Request", false, 400);
|
|
131
|
+
}
|
|
132
|
+
const notification = !Object.hasOwn(raw, "id");
|
|
133
|
+
const id = notification ? null : raw.id;
|
|
134
|
+
if (
|
|
135
|
+
!["hooks/intercept", "hooks/observe", "hooks/capabilities"].includes(
|
|
136
|
+
raw.method,
|
|
137
|
+
)
|
|
138
|
+
) {
|
|
139
|
+
return error(
|
|
140
|
+
id,
|
|
141
|
+
-32601,
|
|
142
|
+
"Method not found",
|
|
143
|
+
notification,
|
|
144
|
+
notification ? 400 : 200,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
object(raw.params) &&
|
|
149
|
+
typeof raw.params.protocolVersion === "string" &&
|
|
150
|
+
raw.params.protocolVersion !== PROTOCOL_VERSION
|
|
151
|
+
) {
|
|
152
|
+
return error(
|
|
153
|
+
id,
|
|
154
|
+
-32001,
|
|
155
|
+
"Unsupported AHP protocol version",
|
|
156
|
+
notification,
|
|
157
|
+
notification ? 400 : 200,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
// Validation and snapshots can exhaust the stack on deeply nested external JSON.
|
|
162
|
+
const decoded =
|
|
163
|
+
raw.method === "hooks/intercept"
|
|
164
|
+
? validateInterceptRequest(raw)
|
|
165
|
+
: raw.method === "hooks/capabilities"
|
|
166
|
+
? validateCapabilitiesRequest(raw)
|
|
167
|
+
: validateWire("observe-notification", raw).length === 0
|
|
168
|
+
? parseObserveNotification(raw)
|
|
169
|
+
: { ok: false as const };
|
|
170
|
+
if (!decoded.ok)
|
|
171
|
+
return error(
|
|
172
|
+
id,
|
|
173
|
+
-32602,
|
|
174
|
+
"Invalid params",
|
|
175
|
+
notification,
|
|
176
|
+
notification ? 400 : 200,
|
|
177
|
+
);
|
|
178
|
+
const message = decoded.value;
|
|
179
|
+
// Retain trusted correlation and advertisements if application code mutates its message.
|
|
180
|
+
const canonical = structuredClone(message);
|
|
181
|
+
const result = await handler(message);
|
|
182
|
+
if (canonical.method === "hooks/observe")
|
|
183
|
+
return new Response(null, { status: result === undefined ? 204 : 500 });
|
|
184
|
+
if (!object(result) || Object.hasOwn(result, "protocolVersion"))
|
|
185
|
+
throw new Error("Invalid result");
|
|
186
|
+
const envelope = {
|
|
187
|
+
jsonrpc: "2.0",
|
|
188
|
+
id: canonical.id,
|
|
189
|
+
result: { ...result, protocolVersion: PROTOCOL_VERSION },
|
|
190
|
+
};
|
|
191
|
+
if (canonical.method === "hooks/intercept") {
|
|
192
|
+
const response = validateInterceptResponse(envelope);
|
|
193
|
+
if (!response.ok || !supported(canonical, response.value))
|
|
194
|
+
throw new Error("Invalid result");
|
|
195
|
+
return json(response.value);
|
|
196
|
+
}
|
|
197
|
+
const response = validateCapabilitiesResponse(envelope);
|
|
198
|
+
if (!response.ok) throw new Error("Invalid result");
|
|
199
|
+
return json(response.value);
|
|
200
|
+
} catch {
|
|
201
|
+
return error(id, -32004, "Backend internal error", notification);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const hooks = { handle };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { hooks } from "./hooks.js";
|
|
2
|
+
export type {
|
|
3
|
+
Message,
|
|
4
|
+
Handler,
|
|
5
|
+
InterceptResult,
|
|
6
|
+
CapabilitiesResult,
|
|
7
|
+
} from "./hooks.js";
|
|
8
|
+
export { attachments, UploadError } from "./attachments.js";
|
|
9
|
+
export type { Upload } from "./attachments.js";
|
|
10
|
+
export type {
|
|
11
|
+
ContentReference,
|
|
12
|
+
InterceptRequest,
|
|
13
|
+
ObserveNotification,
|
|
14
|
+
CapabilitiesRequest,
|
|
15
|
+
Effect,
|
|
16
|
+
} from "../draft/generated.js";
|
|
17
|
+
|
|
18
|
+
export { effects } from "../draft/generated.js";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { stdin, stdout } from "node:process";
|
|
2
|
+
import { Writable, type Readable } from "node:stream";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
|
|
5
|
+
export interface StdioOptions {
|
|
6
|
+
stdin?: Readable;
|
|
7
|
+
stdout?: Writable;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Serve UTF-8 NDJSON using an ordinary Web Request/Response handler.
|
|
12
|
+
* Requests run sequentially. Nonempty bodies are written regardless of status;
|
|
13
|
+
* physical CR/LF in response bodies becomes spaces to preserve line framing.
|
|
14
|
+
* Resolves at EOF after flushing output, without ending stdout. The streams are
|
|
15
|
+
* dedicated to this serving session: failure or abort destroys them. Handlers
|
|
16
|
+
* doing long-running work should honor request.signal. No process is exited.
|
|
17
|
+
*/
|
|
18
|
+
export async function serveStdio(
|
|
19
|
+
handler: (request: Request) => Response | Promise<Response>,
|
|
20
|
+
options: StdioOptions = {},
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const output = options.stdout ?? stdout;
|
|
23
|
+
// A private sink waits for each write callback without ending caller stdout.
|
|
24
|
+
const sink = new Writable({
|
|
25
|
+
highWaterMark: 1,
|
|
26
|
+
write(chunk, _encoding, callback) {
|
|
27
|
+
output.write(chunk, callback);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const outputError = (error: Error) => sink.destroy(error);
|
|
31
|
+
const outputClosed = () => sink.destroy(new Error("Stdio output closed"));
|
|
32
|
+
output.on("error", outputError);
|
|
33
|
+
output.on("close", outputClosed);
|
|
34
|
+
try {
|
|
35
|
+
await pipeline(
|
|
36
|
+
options.stdin ?? stdin,
|
|
37
|
+
async function* (source, { signal }) {
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
let pending = "";
|
|
40
|
+
const respond = async (body: string) => {
|
|
41
|
+
const response = await handler(
|
|
42
|
+
new Request("http://localhost/", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "content-type": "application/json" },
|
|
45
|
+
body,
|
|
46
|
+
signal,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
const text = await response.text();
|
|
50
|
+
return text === "" ? "" : text.replace(/[\r\n]/g, " ") + "\n";
|
|
51
|
+
};
|
|
52
|
+
for await (const chunk of source) {
|
|
53
|
+
pending +=
|
|
54
|
+
typeof chunk === "string"
|
|
55
|
+
? decoder.decode() + chunk
|
|
56
|
+
: decoder.decode(chunk, { stream: true });
|
|
57
|
+
let newline: number;
|
|
58
|
+
while ((newline = pending.indexOf("\n")) !== -1) {
|
|
59
|
+
const line = pending.slice(0, newline);
|
|
60
|
+
pending = pending.slice(newline + 1);
|
|
61
|
+
const reply = await respond(line);
|
|
62
|
+
if (reply !== "") yield reply;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pending += decoder.decode();
|
|
66
|
+
if (pending !== "") {
|
|
67
|
+
const reply = await respond(pending);
|
|
68
|
+
if (reply !== "") yield reply;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
sink,
|
|
72
|
+
options.signal ? { signal: options.signal } : {},
|
|
73
|
+
);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
output.destroy();
|
|
76
|
+
throw error;
|
|
77
|
+
} finally {
|
|
78
|
+
output.removeListener("error", outputError);
|
|
79
|
+
output.removeListener("close", outputClosed);
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { HookOperationalError, asOperationalError } from "./errors.js";
|
|
3
|
+
import { NdjsonDecoder } from "./framing.js";
|
|
4
|
+
import type { Lifecycle } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface LineTransport {
|
|
7
|
+
exchange(line: string, timeoutMs: number): Promise<string>;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function start(
|
|
12
|
+
command: string,
|
|
13
|
+
args: readonly string[],
|
|
14
|
+
): ChildProcessWithoutNullStreams {
|
|
15
|
+
try {
|
|
16
|
+
const child = spawn(command, [...args], {
|
|
17
|
+
shell: false,
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
});
|
|
20
|
+
child.stderr.resume();
|
|
21
|
+
return child;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new HookOperationalError(
|
|
24
|
+
"SPAWN_ERROR",
|
|
25
|
+
`Could not spawn ${command}`,
|
|
26
|
+
{ cause: error },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class PerEventTransport implements LineTransport {
|
|
32
|
+
readonly #command: string;
|
|
33
|
+
readonly #args: readonly string[];
|
|
34
|
+
|
|
35
|
+
constructor(command: string, args: readonly string[]) {
|
|
36
|
+
this.#command = command;
|
|
37
|
+
this.#args = args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
exchange(line: string, timeoutMs: number): Promise<string> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const decoder = new NdjsonDecoder();
|
|
43
|
+
let child: ChildProcessWithoutNullStreams | undefined;
|
|
44
|
+
let response: string | undefined;
|
|
45
|
+
let settled = false;
|
|
46
|
+
const finish = (error?: unknown): void => {
|
|
47
|
+
if (settled) return;
|
|
48
|
+
settled = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
if (child !== undefined && child.exitCode === null) child.kill();
|
|
51
|
+
if (error !== undefined) reject(asOperationalError(error));
|
|
52
|
+
else resolve(response as string);
|
|
53
|
+
};
|
|
54
|
+
const timer = setTimeout(
|
|
55
|
+
() =>
|
|
56
|
+
finish(
|
|
57
|
+
new HookOperationalError(
|
|
58
|
+
"TIMEOUT",
|
|
59
|
+
`Hook exchange exceeded ${timeoutMs} ms`,
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
timeoutMs,
|
|
63
|
+
);
|
|
64
|
+
try {
|
|
65
|
+
child = start(this.#command, this.#args);
|
|
66
|
+
child.once("error", (error: Error) =>
|
|
67
|
+
finish(
|
|
68
|
+
new HookOperationalError(
|
|
69
|
+
"SPAWN_ERROR",
|
|
70
|
+
`Could not run ${this.#command}`,
|
|
71
|
+
{ cause: error },
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
76
|
+
try {
|
|
77
|
+
const frames = decoder.push(chunk);
|
|
78
|
+
if (
|
|
79
|
+
frames.length > 1 ||
|
|
80
|
+
(frames.length === 1 && response !== undefined)
|
|
81
|
+
) {
|
|
82
|
+
throw new HookOperationalError(
|
|
83
|
+
"MALFORMED_JSON_RPC",
|
|
84
|
+
"Backend emitted multiple responses for one request",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (frames.length === 1) response = frames[0];
|
|
88
|
+
} catch (error) {
|
|
89
|
+
finish(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
child.once("close", (code: number | null) => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
try {
|
|
95
|
+
decoder.end();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
finish(error);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (code !== 0) {
|
|
101
|
+
finish(
|
|
102
|
+
new HookOperationalError(
|
|
103
|
+
"IO_ERROR",
|
|
104
|
+
`Backend exited with status ${String(code)}`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
} else if (response === undefined) {
|
|
108
|
+
finish(
|
|
109
|
+
new HookOperationalError(
|
|
110
|
+
"IO_ERROR",
|
|
111
|
+
"Backend exited before returning a response",
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
finish();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
child.stdin.once("error", (error: Error) =>
|
|
119
|
+
finish(
|
|
120
|
+
new HookOperationalError(
|
|
121
|
+
"IO_ERROR",
|
|
122
|
+
"Could not write hook request",
|
|
123
|
+
{ cause: error },
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
child.stdin.end(`${line}\n`);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
finish(error);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
close(): void {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
class PersistentTransport implements LineTransport {
|
|
138
|
+
readonly #command: string;
|
|
139
|
+
readonly #args: readonly string[];
|
|
140
|
+
#child: ChildProcessWithoutNullStreams | undefined;
|
|
141
|
+
#decoder = new NdjsonDecoder();
|
|
142
|
+
#tail: Promise<void> = Promise.resolve();
|
|
143
|
+
|
|
144
|
+
constructor(command: string, args: readonly string[]) {
|
|
145
|
+
this.#command = command;
|
|
146
|
+
this.#args = args;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
exchange(line: string, timeoutMs: number): Promise<string> {
|
|
150
|
+
const result = this.#tail.then(() => this.#exchangeNow(line, timeoutMs));
|
|
151
|
+
this.#tail = result.then(
|
|
152
|
+
() => undefined,
|
|
153
|
+
() => undefined,
|
|
154
|
+
);
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#exchangeNow(line: string, timeoutMs: number): Promise<string> {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
let settled = false;
|
|
161
|
+
const finish = (error?: unknown, response?: string): void => {
|
|
162
|
+
if (settled) return;
|
|
163
|
+
settled = true;
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
child.stdout.off("data", onData);
|
|
166
|
+
child.off("error", onError);
|
|
167
|
+
child.off("close", onClose);
|
|
168
|
+
child.stdin.off("error", onStdinError);
|
|
169
|
+
if (error !== undefined) {
|
|
170
|
+
this.#discard();
|
|
171
|
+
reject(asOperationalError(error));
|
|
172
|
+
} else {
|
|
173
|
+
resolve(response as string);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const timer = setTimeout(
|
|
177
|
+
() =>
|
|
178
|
+
finish(
|
|
179
|
+
new HookOperationalError(
|
|
180
|
+
"TIMEOUT",
|
|
181
|
+
`Hook exchange exceeded ${timeoutMs} ms`,
|
|
182
|
+
),
|
|
183
|
+
),
|
|
184
|
+
timeoutMs,
|
|
185
|
+
);
|
|
186
|
+
let child: ChildProcessWithoutNullStreams;
|
|
187
|
+
try {
|
|
188
|
+
if (this.#child === undefined || this.#child.exitCode !== null) {
|
|
189
|
+
this.#decoder = new NdjsonDecoder();
|
|
190
|
+
this.#child = start(this.#command, this.#args);
|
|
191
|
+
}
|
|
192
|
+
child = this.#child;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
reject(asOperationalError(error));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const onData = (chunk: Buffer): void => {
|
|
199
|
+
try {
|
|
200
|
+
const frames = this.#decoder.push(chunk);
|
|
201
|
+
if (frames.length !== 1) {
|
|
202
|
+
if (frames.length > 1)
|
|
203
|
+
throw new HookOperationalError(
|
|
204
|
+
"MALFORMED_JSON_RPC",
|
|
205
|
+
"Backend emitted multiple responses for one request",
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
finish(undefined, frames[0]);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
finish(error);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const onError = (error: Error): void =>
|
|
215
|
+
finish(
|
|
216
|
+
new HookOperationalError(
|
|
217
|
+
"SPAWN_ERROR",
|
|
218
|
+
`Could not run ${this.#command}`,
|
|
219
|
+
{ cause: error },
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
const onClose = (): void => {
|
|
223
|
+
if (settled) return;
|
|
224
|
+
try {
|
|
225
|
+
this.#decoder.end();
|
|
226
|
+
} catch (error) {
|
|
227
|
+
finish(error);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
finish(
|
|
231
|
+
new HookOperationalError(
|
|
232
|
+
"IO_ERROR",
|
|
233
|
+
"Persistent backend exited before returning a response",
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
const onStdinError = (error: Error): void =>
|
|
238
|
+
finish(
|
|
239
|
+
new HookOperationalError("IO_ERROR", "Could not write hook request", {
|
|
240
|
+
cause: error,
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
child.stdout.on("data", onData);
|
|
244
|
+
child.once("error", onError);
|
|
245
|
+
child.once("close", onClose);
|
|
246
|
+
child.stdin.once("error", onStdinError);
|
|
247
|
+
child.stdin.write(`${line}\n`, (error?: Error | null) => {
|
|
248
|
+
if (error) onStdinError(error);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#discard(): void {
|
|
254
|
+
const child = this.#child;
|
|
255
|
+
this.#child = undefined;
|
|
256
|
+
this.#decoder = new NdjsonDecoder();
|
|
257
|
+
if (child !== undefined && child.exitCode === null) child.kill();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
close(): void {
|
|
261
|
+
this.#discard();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function createTransport(
|
|
266
|
+
command: string,
|
|
267
|
+
args: readonly string[],
|
|
268
|
+
lifecycle: Lifecycle,
|
|
269
|
+
): LineTransport {
|
|
270
|
+
return lifecycle === "persistent"
|
|
271
|
+
? new PersistentTransport(command, args)
|
|
272
|
+
: new PerEventTransport(command, args);
|
|
273
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export const PROTOCOL_VERSION = "draft" as const;
|
|
2
|
+
export const INTERCEPT_METHOD = "hooks/intercept" as const;
|
|
3
|
+
export const TOOL_KINDS = [
|
|
4
|
+
"shell",
|
|
5
|
+
"file_read",
|
|
6
|
+
"file_write",
|
|
7
|
+
"file_edit",
|
|
8
|
+
"search",
|
|
9
|
+
"fetch",
|
|
10
|
+
"task",
|
|
11
|
+
"mcp",
|
|
12
|
+
"other",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type JsonPrimitive = null | boolean | number | string;
|
|
16
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | JsonObject;
|
|
17
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
18
|
+
export type ToolKind = (typeof TOOL_KINDS)[number];
|
|
19
|
+
|
|
20
|
+
export interface SessionContext {
|
|
21
|
+
id: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
workspaceRoots?: string[];
|
|
24
|
+
model?: string;
|
|
25
|
+
agent?: { id: string; type?: string };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolContext {
|
|
29
|
+
callId: string;
|
|
30
|
+
name: string;
|
|
31
|
+
kind: ToolKind;
|
|
32
|
+
input: JsonObject;
|
|
33
|
+
mcp?: JsonObject;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NativeContext {
|
|
37
|
+
provider: string;
|
|
38
|
+
eventName: string;
|
|
39
|
+
payload: JsonObject;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ToolBeforeEvent {
|
|
43
|
+
id: string;
|
|
44
|
+
source: string;
|
|
45
|
+
type: "tool.before";
|
|
46
|
+
time: string;
|
|
47
|
+
session: SessionContext;
|
|
48
|
+
tool: ToolContext;
|
|
49
|
+
native?: NativeContext;
|
|
50
|
+
extensions?: JsonObject;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface InterceptRequest {
|
|
54
|
+
jsonrpc: "2.0";
|
|
55
|
+
id: string;
|
|
56
|
+
method: "hooks/intercept";
|
|
57
|
+
params: {
|
|
58
|
+
protocolVersion: "draft";
|
|
59
|
+
event: ToolBeforeEvent;
|
|
60
|
+
capabilities: { effects: ["deny"] };
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type DenyEffect = {
|
|
65
|
+
type: "deny";
|
|
66
|
+
reason: string;
|
|
67
|
+
code?: string;
|
|
68
|
+
extensions?: JsonObject;
|
|
69
|
+
};
|
|
70
|
+
export type InterceptResult =
|
|
71
|
+
| { protocolVersion: "draft"; effects: [] }
|
|
72
|
+
| { protocolVersion: "draft"; effects: [DenyEffect] };
|
|
73
|
+
|
|
74
|
+
export interface InterceptResponse {
|
|
75
|
+
jsonrpc: "2.0";
|
|
76
|
+
id: string;
|
|
77
|
+
result: InterceptResult;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FailurePolicy = "fail-open" | "fail-closed";
|
|
81
|
+
export type Lifecycle = "per_event" | "persistent";
|
|
82
|
+
|
|
83
|
+
export interface HookBackend {
|
|
84
|
+
command: string;
|
|
85
|
+
args?: readonly string[];
|
|
86
|
+
timeoutMs: number;
|
|
87
|
+
failurePolicy: FailurePolicy;
|
|
88
|
+
retries?: number;
|
|
89
|
+
lifecycle?: Lifecycle;
|
|
90
|
+
name?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ToolBeforeInput {
|
|
94
|
+
source: string;
|
|
95
|
+
eventId?: string;
|
|
96
|
+
time?: string;
|
|
97
|
+
session: Omit<SessionContext, "id"> & { id?: string };
|
|
98
|
+
tool: Omit<ToolContext, "callId"> & { callId?: string };
|
|
99
|
+
native?: NativeContext;
|
|
100
|
+
extensions?: JsonObject;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface HookFailure {
|
|
104
|
+
backend: string;
|
|
105
|
+
policy: FailurePolicy;
|
|
106
|
+
code: string;
|
|
107
|
+
message: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ToolBeforeOutcome {
|
|
111
|
+
decision: "continue" | "deny";
|
|
112
|
+
event: ToolBeforeEvent;
|
|
113
|
+
failures: HookFailure[];
|
|
114
|
+
denial?: {
|
|
115
|
+
backend: string;
|
|
116
|
+
reason: string;
|
|
117
|
+
code?: string;
|
|
118
|
+
extensions?: JsonObject;
|
|
119
|
+
operational: boolean;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type IdKind = "event" | "session" | "call";
|
|
124
|
+
export type IdGenerator = (kind: IdKind) => string;
|