@nebutra/agent-runtime 0.2.0 → 0.2.2

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.
Files changed (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -1,279 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import {
3
- type HookConfig,
4
- type HookContext,
5
- type HookEvent,
6
- matchesMatcher,
7
- onHookEvent,
8
- runHooks,
9
- } from "./hook-pipeline";
10
-
11
- const ctx: HookContext = { tenantId: "org_123", threadId: "th_1" };
12
-
13
- describe("matchesMatcher (pure)", () => {
14
- it('"*" matches anything', () => {
15
- expect(matchesMatcher("*", "Write")).toBe(true);
16
- expect(matchesMatcher("*", "anything")).toBe(true);
17
- });
18
-
19
- it("exact match", () => {
20
- expect(matchesMatcher("Write", "Write")).toBe(true);
21
- expect(matchesMatcher("Write", "Edit")).toBe(false);
22
- });
23
-
24
- it("pipe-list match", () => {
25
- expect(matchesMatcher("Write|Edit", "Edit")).toBe(true);
26
- expect(matchesMatcher("Write|Edit", "Write")).toBe(true);
27
- expect(matchesMatcher("Write|Edit", "Read")).toBe(false);
28
- });
29
-
30
- it("regex match", () => {
31
- expect(matchesMatcher("/^mcp__/", "mcp__github")).toBe(true);
32
- expect(matchesMatcher("/^mcp__/", "Write")).toBe(false);
33
- });
34
- });
35
-
36
- describe("ifCondition gating", () => {
37
- it("skips transport when ifCondition returns false", async () => {
38
- const run = vi.fn(async () => ({ additionalContext: "ran" }));
39
- const hooks: HookConfig[] = [
40
- {
41
- event: "PreToolUse",
42
- matcher: "*",
43
- ifCondition: (input) => (input as { go?: boolean }).go === true,
44
- transport: { kind: "function", run },
45
- },
46
- ];
47
- const out = await runHooks("PreToolUse", { name: "Write", input: { go: false } }, hooks, ctx);
48
- expect(run).not.toHaveBeenCalled();
49
- expect(out.additionalContext).toBeUndefined();
50
- });
51
-
52
- it("runs transport when ifCondition returns true", async () => {
53
- const run = vi.fn(async () => ({ additionalContext: "ran" }));
54
- const hooks: HookConfig[] = [
55
- {
56
- event: "PreToolUse",
57
- matcher: "*",
58
- ifCondition: (input) => (input as { go?: boolean }).go === true,
59
- transport: { kind: "function", run },
60
- },
61
- ];
62
- const out = await runHooks("PreToolUse", { name: "Write", input: { go: true } }, hooks, ctx);
63
- expect(run).toHaveBeenCalledTimes(1);
64
- expect(out.additionalContext).toBe("ran");
65
- });
66
- });
67
-
68
- describe("function transport exit-code lane", () => {
69
- it("code 2 maps stderr to blockingError", async () => {
70
- const hooks: HookConfig[] = [
71
- {
72
- event: "PreToolUse",
73
- matcher: "*",
74
- transport: { kind: "function", run: async () => ({ code: 2, stderr: "nope" }) },
75
- },
76
- ];
77
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
78
- expect(out.blockingError).toBe("nope");
79
- expect(out.preventContinuation).toBe(true);
80
- });
81
-
82
- it("code 0 produces no blocking error", async () => {
83
- const hooks: HookConfig[] = [
84
- {
85
- event: "PreToolUse",
86
- matcher: "*",
87
- transport: { kind: "function", run: async () => ({ code: 0, stdout: "ok" }) },
88
- },
89
- ];
90
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
91
- expect(out.blockingError).toBeUndefined();
92
- });
93
- });
94
-
95
- describe("http transport (fail-closed)", () => {
96
- it("rejects non-https url", async () => {
97
- const hooks: HookConfig[] = [
98
- {
99
- event: "PreToolUse",
100
- matcher: "*",
101
- transport: {
102
- kind: "http",
103
- url: "http://insecure.example.com/hook",
104
- fetchImpl: async () => new Response("{}", { status: 200 }),
105
- },
106
- },
107
- ];
108
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
109
- expect(out.blockingError).toBeDefined();
110
- expect(out.preventContinuation).toBe(true);
111
- });
112
-
113
- it("fail-closed on non-2xx", async () => {
114
- const hooks: HookConfig[] = [
115
- {
116
- event: "PreToolUse",
117
- matcher: "*",
118
- transport: {
119
- kind: "http",
120
- url: "https://hooks.example.com/h",
121
- fetchImpl: async () => new Response("err", { status: 500 }),
122
- },
123
- },
124
- ];
125
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
126
- expect(out.blockingError).toBeDefined();
127
- expect(out.preventContinuation).toBe(true);
128
- });
129
-
130
- it("2xx parses partial outcome JSON body", async () => {
131
- const hooks: HookConfig[] = [
132
- {
133
- event: "PreToolUse",
134
- matcher: "*",
135
- transport: {
136
- kind: "http",
137
- url: "https://hooks.example.com/h",
138
- fetchImpl: async () =>
139
- new Response(JSON.stringify({ additionalContext: "from-http" }), { status: 200 }),
140
- },
141
- },
142
- ];
143
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
144
- expect(out.additionalContext).toBe("from-http");
145
- });
146
- });
147
-
148
- describe("prompt transport", () => {
149
- it("delegates to injected llmEval", async () => {
150
- const hooks: HookConfig[] = [
151
- {
152
- event: "PreToolUse",
153
- matcher: "*",
154
- transport: {
155
- kind: "prompt",
156
- model: "test-model",
157
- llmEval: async () => ({ permissionBehavior: "deny", stopReason: "policy" }),
158
- },
159
- },
160
- ];
161
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
162
- expect(out.permissionBehavior).toBe("deny");
163
- });
164
- });
165
-
166
- describe("parallel fan-out + precedence merge", () => {
167
- it("deny beats allow; contexts concatenated stably; last updatedInput wins", async () => {
168
- const hooks: HookConfig[] = [
169
- {
170
- event: "PreToolUse",
171
- matcher: "*",
172
- transport: {
173
- kind: "function",
174
- run: async () => ({
175
- permissionBehavior: "allow",
176
- additionalContext: "A",
177
- updatedInput: { v: 1 },
178
- }),
179
- },
180
- },
181
- {
182
- event: "PreToolUse",
183
- matcher: "Write",
184
- transport: {
185
- kind: "function",
186
- run: async () => ({
187
- permissionBehavior: "deny",
188
- additionalContext: "B",
189
- updatedInput: { v: 2 },
190
- }),
191
- },
192
- },
193
- ];
194
- const out = await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
195
- expect(out.permissionBehavior).toBe("deny");
196
- expect(out.additionalContext).toBe("A\nB");
197
- expect(out.updatedInput).toEqual({ v: 2 });
198
- });
199
-
200
- it("preventContinuation halts and surfaces stopReason", async () => {
201
- const hooks: HookConfig[] = [
202
- {
203
- event: "Stop",
204
- matcher: "*",
205
- transport: {
206
- kind: "function",
207
- run: async () => ({ preventContinuation: true, stopReason: "halt" }),
208
- },
209
- },
210
- ];
211
- const out = await runHooks("Stop", { name: "Stop", input: {} }, hooks, ctx);
212
- expect(out.preventContinuation).toBe(true);
213
- expect(out.stopReason).toBe("halt");
214
- });
215
-
216
- it("only runs hooks matching event + matcher", async () => {
217
- const ran = vi.fn(async () => ({ additionalContext: "x" }));
218
- const skipped = vi.fn(async () => ({ additionalContext: "y" }));
219
- const hooks: HookConfig[] = [
220
- { event: "PreToolUse", matcher: "Write", transport: { kind: "function", run: ran } },
221
- { event: "PreToolUse", matcher: "Read", transport: { kind: "function", run: skipped } },
222
- { event: "PostToolUse", matcher: "*", transport: { kind: "function", run: skipped } },
223
- ];
224
- await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
225
- expect(ran).toHaveBeenCalledTimes(1);
226
- expect(skipped).not.toHaveBeenCalled();
227
- });
228
- });
229
-
230
- describe("multi-tenant fail-closed", () => {
231
- it("throws when tenantId is empty", async () => {
232
- await expect(
233
- runHooks("PreToolUse", { name: "Write", input: {} }, [], {
234
- tenantId: "",
235
- threadId: "th_1",
236
- }),
237
- ).rejects.toThrow();
238
- });
239
- });
240
-
241
- describe("progress emitter", () => {
242
- it("fires started + completed decoupled from outcomes", async () => {
243
- const events: string[] = [];
244
- const off = onHookEvent((e) => events.push(`${e.phase}:${e.event}`));
245
- const hooks: HookConfig[] = [
246
- { event: "PreToolUse", matcher: "*", transport: { kind: "function", run: async () => ({}) } },
247
- ];
248
- await runHooks("PreToolUse", { name: "Write", input: {} }, hooks, ctx);
249
- off();
250
- expect(events).toContain("started:PreToolUse");
251
- expect(events).toContain("completed:PreToolUse");
252
- });
253
- });
254
-
255
- describe("HookEvent taxonomy", () => {
256
- it("type accepts the full event union", () => {
257
- const all: HookEvent[] = [
258
- "PreToolUse",
259
- "PostToolUse",
260
- "PostToolUseFailure",
261
- "UserPromptSubmit",
262
- "SessionStart",
263
- "SessionEnd",
264
- "Stop",
265
- "StopFailure",
266
- "SubagentStart",
267
- "SubagentStop",
268
- "PreCompact",
269
- "PostCompact",
270
- "PermissionRequest",
271
- "PermissionDenied",
272
- "TaskCreated",
273
- "TaskCompleted",
274
- "ConfigChange",
275
- "FileChanged",
276
- ];
277
- expect(all).toHaveLength(18);
278
- });
279
- });
@@ -1,373 +0,0 @@
1
- /**
2
- * Hook pipeline (WRAP — config-driven orchestrator hooks).
3
- *
4
- * Faithful re-expression of the upstream config-driven, decision-returning
5
- * hook system. This is the configuration layer ABOVE the bare {@link ToolHooks}
6
- * interface in `./tools.js`: events are matched by an event + matcher
7
- * (+ optional ifCondition), fanned out to transports in parallel, and merged
8
- * into a single deterministic {@link HookOutcome}.
9
- *
10
- * MULTI-TENANT SAFETY — the upstream `command`/shell/async (host-process)
11
- * transports are deliberately DROPPED. In a multi-tenant runtime, executing a
12
- * tenant-supplied host command is unbounded RCE against the shared process and
13
- * cannot be sandboxed at this layer. Only in-process `function`, caller-fetched
14
- * `http`, and caller-injected `prompt` transports are supported — each fails
15
- * closed and cannot reach the host shell.
16
- */
17
-
18
- import { z } from "zod";
19
- import type { ToolDispatchContext, ToolHooks } from "./tools";
20
-
21
- /**
22
- * Event taxonomy — ported verbatim from the upstream hook system.
23
- * The matcher is matched against the tool name (tool events) or the event
24
- * name itself (lifecycle events).
25
- */
26
- export type HookEvent =
27
- | "PreToolUse"
28
- | "PostToolUse"
29
- | "PostToolUseFailure"
30
- | "UserPromptSubmit"
31
- | "SessionStart"
32
- | "SessionEnd"
33
- | "Stop"
34
- | "StopFailure"
35
- | "SubagentStart"
36
- | "SubagentStop"
37
- | "PreCompact"
38
- | "PostCompact"
39
- | "PermissionRequest"
40
- | "PermissionDenied"
41
- | "TaskCreated"
42
- | "TaskCompleted"
43
- | "ConfigChange"
44
- | "FileChanged";
45
-
46
- /** Tenant-scoped context — reuses the dispatch context shape from `./tools.js`. */
47
- export type HookContext = ToolDispatchContext;
48
-
49
- /** The event payload presented to a transport. */
50
- export interface HookPayload {
51
- /** The tool name (tool events) or event name (lifecycle events). */
52
- readonly name: string;
53
- /** Parsed tool/event input. */
54
- readonly input: unknown;
55
- /** Tool output (only present for Post* events). */
56
- readonly toolOutput?: unknown;
57
- }
58
-
59
- /**
60
- * Decision protocol — ported verbatim. A transport returns a PARTIAL outcome;
61
- * the pipeline merges all partials with deterministic precedence.
62
- */
63
- export interface HookOutcome {
64
- readonly preventContinuation?: boolean | undefined;
65
- readonly stopReason?: string | undefined;
66
- readonly permissionBehavior?: "allow" | "deny" | "ask" | undefined;
67
- readonly blockingError?: string | undefined;
68
- readonly additionalContext?: string | undefined;
69
- readonly updatedInput?: unknown;
70
- readonly updatedToolOutput?: unknown;
71
- readonly systemMessage?: string | undefined;
72
- }
73
-
74
- /** Exit-code-style lane for the function transport. */
75
- export interface HookExitResult {
76
- readonly code: number;
77
- readonly stdout?: string;
78
- readonly stderr?: string;
79
- }
80
-
81
- type FunctionRunResult = Partial<HookOutcome> | HookExitResult;
82
-
83
- /** In-process transport — the tenant-safe default. */
84
- export interface FunctionTransport {
85
- readonly kind: "function";
86
- readonly run: (payload: HookPayload, ctx: HookContext) => Promise<FunctionRunResult>;
87
- }
88
-
89
- /** HTTP transport — caller injects `fetchImpl`; absolute https + fail-closed. */
90
- export interface HttpTransport {
91
- readonly kind: "http";
92
- readonly url: string;
93
- readonly allowEnvVars?: readonly string[];
94
- readonly fetchImpl: (url: string, init: RequestInit) => Promise<Response>;
95
- }
96
-
97
- /** Prompt transport — caller injects the LLM evaluator. */
98
- export interface PromptTransport {
99
- readonly kind: "prompt";
100
- readonly model: string;
101
- readonly llmEval: (payload: HookPayload, ctx: HookContext) => Promise<Partial<HookOutcome>>;
102
- }
103
-
104
- export type HookTransport = FunctionTransport | HttpTransport | PromptTransport;
105
-
106
- /** A single hook configuration entry. */
107
- export interface HookConfig {
108
- readonly event: HookEvent;
109
- /** `"*"` | exact | pipe-list (`"Write|Edit"`) | `/regex/`. */
110
- readonly matcher: string;
111
- /** Predicate over parsed input, evaluated BEFORE the transport runs. */
112
- readonly ifCondition?: (input: unknown, ctx: HookContext) => boolean;
113
- readonly transport: HookTransport;
114
- }
115
-
116
- /** Out-of-band progress event — decoupled from outcomes. */
117
- export interface HookProgressEvent {
118
- readonly phase: "started" | "completed";
119
- readonly event: HookEvent;
120
- readonly matcher: string;
121
- readonly tenantId: string;
122
- }
123
-
124
- type HookProgressListener = (e: HookProgressEvent) => void;
125
-
126
- const listeners = new Set<HookProgressListener>();
127
-
128
- /** Subscribe to out-of-band hook progress. Returns an unsubscribe function. */
129
- export function onHookEvent(listener: HookProgressListener): () => void {
130
- listeners.add(listener);
131
- return () => {
132
- listeners.delete(listener);
133
- };
134
- }
135
-
136
- function emit(e: HookProgressEvent): void {
137
- for (const l of listeners) {
138
- try {
139
- l(e);
140
- } catch {
141
- // progress listeners must never affect pipeline outcomes
142
- }
143
- }
144
- }
145
-
146
- /**
147
- * Pure matcher: `"*"` | exact | pipe-list | `/regex/`.
148
- */
149
- export function matchesMatcher(matcher: string, name: string): boolean {
150
- if (matcher === "*") return true;
151
- if (matcher.length >= 2 && matcher.startsWith("/") && matcher.endsWith("/")) {
152
- try {
153
- return new RegExp(matcher.slice(1, -1)).test(name);
154
- } catch {
155
- return false;
156
- }
157
- }
158
- if (matcher.includes("|")) {
159
- return matcher.split("|").some((m) => m === name);
160
- }
161
- return matcher === name;
162
- }
163
-
164
- const partialOutcomeSchema: z.ZodType<Partial<HookOutcome>> = z
165
- .object({
166
- preventContinuation: z.boolean().optional(),
167
- stopReason: z.string().optional(),
168
- permissionBehavior: z.enum(["allow", "deny", "ask"]).optional(),
169
- blockingError: z.string().optional(),
170
- additionalContext: z.string().optional(),
171
- updatedInput: z.unknown().optional(),
172
- updatedToolOutput: z.unknown().optional(),
173
- systemMessage: z.string().optional(),
174
- })
175
- .strict()
176
- .partial();
177
-
178
- function isExitResult(r: FunctionRunResult): r is HookExitResult {
179
- return typeof (r as HookExitResult).code === "number";
180
- }
181
-
182
- function exitToOutcome(r: HookExitResult): Partial<HookOutcome> {
183
- if (r.code === 2) {
184
- return {
185
- preventContinuation: true,
186
- blockingError: (r.stderr ?? "").trim() || "hook exited with code 2",
187
- };
188
- }
189
- if (r.code !== 0) {
190
- return {
191
- preventContinuation: true,
192
- blockingError: (r.stderr ?? "").trim() || `hook exited with code ${r.code}`,
193
- };
194
- }
195
- return r.stdout ? { additionalContext: r.stdout } : {};
196
- }
197
-
198
- async function runTransport(
199
- transport: HookTransport,
200
- payload: HookPayload,
201
- ctx: HookContext,
202
- ): Promise<Partial<HookOutcome>> {
203
- if (transport.kind === "function") {
204
- const result = await transport.run(payload, ctx);
205
- if (isExitResult(result)) return exitToOutcome(result);
206
- return partialOutcomeSchema.parse(result);
207
- }
208
-
209
- if (transport.kind === "prompt") {
210
- const result = await transport.llmEval(payload, ctx);
211
- return partialOutcomeSchema.parse(result);
212
- }
213
-
214
- // http — fail-closed on non-https / non-2xx / unparseable body.
215
- let url: URL;
216
- try {
217
- url = new URL(transport.url);
218
- } catch {
219
- return failClosed(`invalid hook url: ${transport.url}`);
220
- }
221
- if (url.protocol !== "https:") {
222
- return failClosed(`hook url must be https (got ${url.protocol})`);
223
- }
224
- try {
225
- const res = await transport.fetchImpl(url.toString(), {
226
- method: "POST",
227
- headers: { "content-type": "application/json", "x-tenant-id": ctx.tenantId },
228
- body: JSON.stringify({ payload, tenantId: ctx.tenantId, threadId: ctx.threadId }),
229
- });
230
- if (res.status < 200 || res.status >= 300) {
231
- return failClosed(`hook endpoint returned ${res.status}`);
232
- }
233
- const json: unknown = await res.json();
234
- return partialOutcomeSchema.parse(json);
235
- } catch (err) {
236
- return failClosed(`hook transport failed: ${String(err)}`);
237
- }
238
- }
239
-
240
- function failClosed(reason: string): Partial<HookOutcome> {
241
- return { preventContinuation: true, blockingError: reason };
242
- }
243
-
244
- const DENY_PRECEDENCE: ReadonlyArray<HookOutcome["permissionBehavior"]> = ["deny", "ask", "allow"];
245
-
246
- function mergeOutcomes(parts: ReadonlyArray<Partial<HookOutcome>>): HookOutcome {
247
- const contexts: string[] = [];
248
- let permission: HookOutcome["permissionBehavior"];
249
- let prevent = false;
250
- let blockingError: string | undefined;
251
- let stopReason: string | undefined;
252
- let systemMessage: string | undefined;
253
- let updatedInput: { value: unknown } | undefined;
254
- let updatedToolOutput: { value: unknown } | undefined;
255
-
256
- for (const p of parts) {
257
- if (p.additionalContext !== undefined) contexts.push(p.additionalContext);
258
- if (p.preventContinuation) prevent = true;
259
- if (p.blockingError !== undefined && blockingError === undefined) {
260
- blockingError = p.blockingError;
261
- }
262
- if (p.stopReason !== undefined && stopReason === undefined) stopReason = p.stopReason;
263
- if (p.systemMessage !== undefined && systemMessage === undefined) {
264
- systemMessage = p.systemMessage;
265
- }
266
- if (p.permissionBehavior !== undefined) {
267
- if (
268
- permission === undefined ||
269
- DENY_PRECEDENCE.indexOf(p.permissionBehavior) < DENY_PRECEDENCE.indexOf(permission)
270
- ) {
271
- permission = p.permissionBehavior;
272
- }
273
- }
274
- // last writer wins, in stable (input) order
275
- if ("updatedInput" in p) updatedInput = { value: p.updatedInput };
276
- if ("updatedToolOutput" in p) updatedToolOutput = { value: p.updatedToolOutput };
277
- }
278
-
279
- if (blockingError !== undefined) prevent = true;
280
- if (permission === "deny") prevent = prevent || false;
281
-
282
- const outcome: Mutable<HookOutcome> = {};
283
- if (prevent) outcome.preventContinuation = true;
284
- if (blockingError !== undefined) outcome.blockingError = blockingError;
285
- if (stopReason !== undefined) outcome.stopReason = stopReason;
286
- if (systemMessage !== undefined) outcome.systemMessage = systemMessage;
287
- if (permission !== undefined) outcome.permissionBehavior = permission;
288
- if (contexts.length > 0) outcome.additionalContext = contexts.join("\n");
289
- if (updatedInput !== undefined) outcome.updatedInput = updatedInput.value;
290
- if (updatedToolOutput !== undefined) outcome.updatedToolOutput = updatedToolOutput.value;
291
- return outcome;
292
- }
293
-
294
- type Mutable<T> = { -readonly [K in keyof T]: T[K] };
295
-
296
- /**
297
- * Select matching hooks, fan out in parallel, merge with deterministic
298
- * precedence. tenantId is mandatory — empty fails closed (throws).
299
- */
300
- export async function runHooks(
301
- event: HookEvent,
302
- payload: HookPayload,
303
- hooks: ReadonlyArray<HookConfig>,
304
- ctx: HookContext,
305
- ): Promise<HookOutcome> {
306
- if (!ctx || typeof ctx.tenantId !== "string" || ctx.tenantId.trim() === "") {
307
- throw new Error("runHooks: tenantId is mandatory and must be non-empty (fail-closed)");
308
- }
309
-
310
- const selected = hooks.filter(
311
- (h) =>
312
- h.event === event &&
313
- matchesMatcher(h.matcher, payload.name) &&
314
- (h.ifCondition === undefined || h.ifCondition(payload.input, ctx) === true),
315
- );
316
-
317
- if (selected.length === 0) return {};
318
-
319
- // Stable order = config order, preserved despite parallel execution.
320
- const results = await Promise.all(
321
- selected.map(async (h) => {
322
- emit({ phase: "started", event, matcher: h.matcher, tenantId: ctx.tenantId });
323
- try {
324
- return await runTransport(h.transport, payload, ctx);
325
- } catch (err) {
326
- return failClosed(`hook crashed: ${String(err)}`);
327
- } finally {
328
- emit({ phase: "completed", event, matcher: h.matcher, tenantId: ctx.tenantId });
329
- }
330
- }),
331
- );
332
-
333
- return mergeOutcomes(results);
334
- }
335
-
336
- /**
337
- * Adapt the bare {@link ToolHooks} interface (from `./tools.js`) into
338
- * config-driven function-transport entries — the in-process transport feeding
339
- * the lower layer. preToolUse/postToolUse are void-returning, so the produced
340
- * outcome is always empty (they observe; they do not decide here).
341
- */
342
- export function fromToolHooks(toolHooks: ToolHooks): HookConfig[] {
343
- const configs: HookConfig[] = [];
344
- if (toolHooks.preToolUse) {
345
- const pre = toolHooks.preToolUse;
346
- configs.push({
347
- event: "PreToolUse",
348
- matcher: "*",
349
- transport: {
350
- kind: "function",
351
- run: async (payload, ctx) => {
352
- await pre(payload.name, payload.input, ctx);
353
- return {};
354
- },
355
- },
356
- });
357
- }
358
- if (toolHooks.postToolUse) {
359
- const post = toolHooks.postToolUse;
360
- configs.push({
361
- event: "PostToolUse",
362
- matcher: "*",
363
- transport: {
364
- kind: "function",
365
- run: async (payload, ctx) => {
366
- await post(payload.name, payload.toolOutput, ctx);
367
- return {};
368
- },
369
- },
370
- });
371
- }
372
- return configs;
373
- }