@nebutra/agent-runtime 0.2.1 → 2.0.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.
Files changed (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.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
- }