@clinebot/core 0.0.11 → 0.0.12

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 (53) hide show
  1. package/dist/agents/agent-config-loader.d.ts +1 -1
  2. package/dist/agents/agent-config-parser.d.ts +5 -2
  3. package/dist/agents/index.d.ts +1 -1
  4. package/dist/agents/plugin-config-loader.d.ts +4 -0
  5. package/dist/agents/plugin-sandbox-bootstrap.js +446 -0
  6. package/dist/agents/plugin-sandbox.d.ts +4 -0
  7. package/dist/index.node.d.ts +1 -0
  8. package/dist/index.node.js +658 -407
  9. package/dist/runtime/sandbox/subprocess-sandbox.d.ts +8 -1
  10. package/dist/session/default-session-manager.d.ts +5 -0
  11. package/dist/session/session-config-builder.d.ts +4 -1
  12. package/dist/session/session-manager.d.ts +1 -0
  13. package/dist/session/unified-session-persistence-service.d.ts +6 -0
  14. package/dist/session/utils/types.d.ts +9 -0
  15. package/dist/tools/definitions.d.ts +2 -2
  16. package/dist/tools/presets.d.ts +3 -3
  17. package/dist/tools/schemas.d.ts +14 -14
  18. package/dist/types/config.d.ts +5 -0
  19. package/dist/types/events.d.ts +22 -0
  20. package/package.json +5 -4
  21. package/src/agents/agent-config-loader.test.ts +2 -0
  22. package/src/agents/agent-config-loader.ts +1 -0
  23. package/src/agents/agent-config-parser.ts +12 -5
  24. package/src/agents/index.ts +1 -0
  25. package/src/agents/plugin-config-loader.test.ts +49 -0
  26. package/src/agents/plugin-config-loader.ts +10 -73
  27. package/src/agents/plugin-loader.test.ts +128 -2
  28. package/src/agents/plugin-loader.ts +70 -5
  29. package/src/agents/plugin-sandbox-bootstrap.ts +445 -0
  30. package/src/agents/plugin-sandbox.test.ts +198 -1
  31. package/src/agents/plugin-sandbox.ts +223 -353
  32. package/src/index.node.ts +4 -0
  33. package/src/runtime/hook-file-hooks.test.ts +1 -1
  34. package/src/runtime/hook-file-hooks.ts +16 -6
  35. package/src/runtime/runtime-builder.test.ts +67 -0
  36. package/src/runtime/runtime-builder.ts +70 -16
  37. package/src/runtime/sandbox/subprocess-sandbox.ts +35 -11
  38. package/src/session/default-session-manager.e2e.test.ts +20 -1
  39. package/src/session/default-session-manager.test.ts +453 -1
  40. package/src/session/default-session-manager.ts +200 -0
  41. package/src/session/session-config-builder.ts +2 -0
  42. package/src/session/session-manager.ts +1 -0
  43. package/src/session/session-team-coordination.ts +30 -0
  44. package/src/session/unified-session-persistence-service.ts +45 -0
  45. package/src/session/utils/types.ts +10 -0
  46. package/src/storage/sqlite-team-store.ts +16 -5
  47. package/src/tools/definitions.test.ts +87 -8
  48. package/src/tools/definitions.ts +89 -70
  49. package/src/tools/presets.test.ts +2 -3
  50. package/src/tools/presets.ts +3 -3
  51. package/src/tools/schemas.ts +23 -22
  52. package/src/types/config.ts +5 -0
  53. package/src/types/events.ts +23 -0
@@ -1,3 +1,6 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import type { AgentConfig, Tool } from "@clinebot/agents";
2
5
  import { SubprocessSandbox } from "../runtime/sandbox/subprocess-sandbox";
3
6
 
@@ -7,6 +10,7 @@ export interface PluginSandboxOptions {
7
10
  importTimeoutMs?: number;
8
11
  hookTimeoutMs?: number;
9
12
  contributionTimeoutMs?: number;
13
+ onEvent?: (event: { name: string; payload?: unknown }) => void;
10
14
  }
11
15
 
12
16
  type AgentExtension = NonNullable<AgentConfig["extensions"]>[number];
@@ -48,192 +52,87 @@ type SandboxedPluginDescriptor = {
48
52
  };
49
53
  };
50
54
 
51
- const PLUGIN_SANDBOX_BOOTSTRAP = `
52
- const { pathToFileURL } = require("node:url");
53
- let pluginCounter = 0;
54
- const pluginState = new Map();
55
-
56
- function toErrorPayload(error) {
57
- const message = error instanceof Error ? error.message : String(error);
58
- const stack = error instanceof Error ? error.stack : undefined;
59
- return { message, stack };
60
- }
61
-
62
- function sendResponse(id, ok, result, error) {
63
- if (!process.send) return;
64
- process.send({ type: "response", id, ok, result, error });
65
- }
66
-
67
- function sanitizeObject(value) {
68
- if (!value || typeof value !== "object") return {};
69
- return value;
70
- }
71
-
72
- async function initialize(args) {
73
- const descriptors = [];
74
- const exportName = (args && args.exportName) || "plugin";
75
- for (const pluginPath of args.pluginPaths || []) {
76
- const moduleExports = await import(pathToFileURL(pluginPath).href);
77
- const plugin = moduleExports.default || moduleExports[exportName];
78
- if (!plugin || typeof plugin !== "object") {
79
- throw new Error(\`Invalid plugin module: \${pluginPath}\`);
80
- }
81
- if (typeof plugin.name !== "string" || !plugin.name) {
82
- throw new Error(\`Invalid plugin name: \${pluginPath}\`);
83
- }
84
- if (!plugin.manifest || typeof plugin.manifest !== "object") {
85
- throw new Error(\`Invalid plugin manifest: \${pluginPath}\`);
86
- }
87
-
88
- const pluginId = \`plugin_\${++pluginCounter}\`;
89
- const contributions = {
90
- tools: [],
91
- commands: [],
92
- shortcuts: [],
93
- flags: [],
94
- messageRenderers: [],
95
- providers: [],
96
- };
97
- const handlers = {
98
- tools: new Map(),
99
- commands: new Map(),
100
- messageRenderers: new Map(),
101
- };
102
-
103
- const makeId = (prefix) => \`\${pluginId}_\${prefix}_\${Math.random().toString(36).slice(2, 10)}\`;
104
- const api = {
105
- registerTool: (tool) => {
106
- const id = makeId("tool");
107
- handlers.tools.set(id, tool.execute);
108
- contributions.tools.push({
109
- id,
110
- name: tool.name,
111
- description: tool.description,
112
- inputSchema: tool.inputSchema,
113
- timeoutMs: tool.timeoutMs,
114
- retryable: tool.retryable,
115
- });
116
- },
117
- registerCommand: (command) => {
118
- const id = makeId("command");
119
- if (typeof command.handler === "function") {
120
- handlers.commands.set(id, command.handler);
121
- }
122
- contributions.commands.push({
123
- id,
124
- name: command.name,
125
- description: command.description,
126
- });
127
- },
128
- registerShortcut: (shortcut) => {
129
- contributions.shortcuts.push({
130
- id: makeId("shortcut"),
131
- name: shortcut.name,
132
- value: shortcut.value,
133
- description: shortcut.description,
134
- });
135
- },
136
- registerFlag: (flag) => {
137
- contributions.flags.push({
138
- id: makeId("flag"),
139
- name: flag.name,
140
- description: flag.description,
141
- defaultValue: flag.defaultValue,
142
- });
143
- },
144
- registerMessageRenderer: (renderer) => {
145
- const id = makeId("renderer");
146
- handlers.messageRenderers.set(id, renderer.render);
147
- contributions.messageRenderers.push({ id, name: renderer.name });
148
- },
149
- registerProvider: (provider) => {
150
- contributions.providers.push({
151
- id: makeId("provider"),
152
- name: provider.name,
153
- description: provider.description,
154
- metadata: sanitizeObject(provider.metadata),
155
- });
156
- },
157
- };
158
-
159
- if (typeof plugin.setup === "function") {
160
- await plugin.setup(api);
161
- }
162
-
163
- pluginState.set(pluginId, { plugin, handlers });
164
- descriptors.push({
165
- pluginId,
166
- name: plugin.name,
167
- manifest: plugin.manifest,
168
- contributions,
169
- });
170
- }
171
- return descriptors;
172
- }
173
-
174
- function getPlugin(pluginId) {
175
- const state = pluginState.get(pluginId);
176
- if (!state) {
177
- throw new Error(\`Unknown sandbox plugin id: \${pluginId}\`);
178
- }
179
- return state;
180
- }
181
-
182
- async function invokeHook(args) {
183
- const state = getPlugin(args.pluginId);
184
- const handler = state.plugin[args.hookName];
185
- if (typeof handler !== "function") {
186
- return undefined;
187
- }
188
- return await handler(args.payload);
189
- }
190
-
191
- async function executeTool(args) {
192
- const state = getPlugin(args.pluginId);
193
- const handler = state.handlers.tools.get(args.contributionId);
194
- if (typeof handler !== "function") {
195
- throw new Error("Unknown sandbox tool contribution");
196
- }
197
- return await handler(args.input, args.context);
198
- }
199
-
200
- async function executeCommand(args) {
201
- const state = getPlugin(args.pluginId);
202
- const handler = state.handlers.commands.get(args.contributionId);
203
- if (typeof handler !== "function") {
204
- return "";
205
- }
206
- return await handler(args.input);
207
- }
208
-
209
- async function renderMessage(args) {
210
- const state = getPlugin(args.pluginId);
211
- const handler = state.handlers.messageRenderers.get(args.contributionId);
212
- if (typeof handler !== "function") {
213
- return "";
214
- }
215
- return await handler(args.message);
55
+ /**
56
+ * Resolve the bootstrap for the sandbox subprocess.
57
+ *
58
+ * In production (bundled), the compiled `.js` file lives next to this module
59
+ * and can be passed directly as a file to spawn. In development/test
60
+ * (unbundled, where only the `.ts` source exists), we load the TypeScript
61
+ * bootstrap through jiti from an inline script.
62
+ */
63
+ function resolveBootstrap(): { file: string } | { script: string } {
64
+ const dir = dirname(fileURLToPath(import.meta.url));
65
+ // In dev, the bootstrap sits next to this file in src/agents/.
66
+ // In production, the main bundle is at dist/ but bootstrap is at dist/agents/.
67
+ const candidates = [
68
+ join(dir, "plugin-sandbox-bootstrap.js"),
69
+ join(dir, "agents", "plugin-sandbox-bootstrap.js"),
70
+ ];
71
+ for (const candidate of candidates) {
72
+ if (existsSync(candidate)) return { file: candidate };
73
+ }
74
+ const tsPath = join(dir, "plugin-sandbox-bootstrap.ts");
75
+ return {
76
+ script: [
77
+ "const createJiti = require('jiti');",
78
+ `const jiti = createJiti(${JSON.stringify(tsPath)}, { cache: false, requireCache: false, esmResolve: true, interopDefault: false });`,
79
+ `Promise.resolve(jiti.import(${JSON.stringify(tsPath)}, {})).catch((error) => {`,
80
+ " console.error(error);",
81
+ " process.exitCode = 1;",
82
+ "});",
83
+ ].join("\n"),
84
+ };
216
85
  }
217
86
 
218
- const methods = { initialize, invokeHook, executeTool, executeCommand, renderMessage };
219
-
220
- process.on("message", async (message) => {
221
- if (!message || message.type !== "call") {
222
- return;
223
- }
224
- const method = methods[message.method];
225
- if (!method) {
226
- sendResponse(message.id, false, undefined, { message: \`Unknown method: \${String(message.method)}\` });
227
- return;
228
- }
229
- try {
230
- const result = await method(message.args || {});
231
- sendResponse(message.id, true, result);
232
- } catch (error) {
233
- sendResponse(message.id, false, undefined, toErrorPayload(error));
234
- }
235
- });
236
- `;
87
+ const BOOTSTRAP = resolveBootstrap();
88
+
89
+ /**
90
+ * Map from hook stage names in the manifest to the property name on AgentExtension
91
+ * and the corresponding hook method name inside the sandbox subprocess.
92
+ */
93
+ const HOOK_BINDINGS: Array<{
94
+ stage: HookStage;
95
+ extensionKey: keyof AgentExtension;
96
+ sandboxHookName: string;
97
+ }> = [
98
+ { stage: "input", extensionKey: "onInput", sandboxHookName: "onInput" },
99
+ {
100
+ stage: "session_start",
101
+ extensionKey: "onSessionStart",
102
+ sandboxHookName: "onSessionStart",
103
+ },
104
+ {
105
+ stage: "before_agent_start",
106
+ extensionKey: "onBeforeAgentStart",
107
+ sandboxHookName: "onBeforeAgentStart",
108
+ },
109
+ {
110
+ stage: "tool_call_before",
111
+ extensionKey: "onToolCall",
112
+ sandboxHookName: "onToolCall",
113
+ },
114
+ {
115
+ stage: "tool_call_after",
116
+ extensionKey: "onToolResult",
117
+ sandboxHookName: "onToolResult",
118
+ },
119
+ {
120
+ stage: "turn_end",
121
+ extensionKey: "onAgentEnd",
122
+ sandboxHookName: "onAgentEnd",
123
+ },
124
+ {
125
+ stage: "session_shutdown",
126
+ extensionKey: "onSessionShutdown",
127
+ sandboxHookName: "onSessionShutdown",
128
+ },
129
+ {
130
+ stage: "runtime_event",
131
+ extensionKey: "onRuntimeEvent",
132
+ sandboxHookName: "onRuntimeEvent",
133
+ },
134
+ { stage: "error", extensionKey: "onError", sandboxHookName: "onError" },
135
+ ];
237
136
 
238
137
  function hasHookStage(extension: AgentExtension, stage: HookStage): boolean {
239
138
  return extension.manifest.hookStages?.includes(stage) === true;
@@ -254,7 +153,10 @@ export async function loadSandboxedPlugins(
254
153
  }> {
255
154
  const sandbox = new SubprocessSandbox({
256
155
  name: "plugin-sandbox",
257
- bootstrapScript: PLUGIN_SANDBOX_BOOTSTRAP,
156
+ ...("file" in BOOTSTRAP
157
+ ? { bootstrapFile: BOOTSTRAP.file }
158
+ : { bootstrapScript: BOOTSTRAP.script }),
159
+ onEvent: options.onEvent,
258
160
  });
259
161
  const importTimeoutMs = withTimeoutFallback(options.importTimeoutMs, 4000);
260
162
  const hookTimeoutMs = withTimeoutFallback(options.hookTimeoutMs, 3000);
@@ -286,177 +188,13 @@ export async function loadSandboxedPlugins(
286
188
  name: descriptor.name,
287
189
  manifest: descriptor.manifest,
288
190
  setup: (api: AgentExtensionApi) => {
289
- for (const toolDescriptor of descriptor.contributions.tools) {
290
- const tool: Tool = {
291
- name: toolDescriptor.name,
292
- description: toolDescriptor.description ?? "",
293
- inputSchema: (toolDescriptor.inputSchema ?? {
294
- type: "object",
295
- properties: {},
296
- }) as Tool["inputSchema"],
297
- timeoutMs: toolDescriptor.timeoutMs,
298
- retryable: toolDescriptor.retryable,
299
- execute: async (input: unknown, context: unknown) =>
300
- await sandbox.call(
301
- "executeTool",
302
- {
303
- pluginId: descriptor.pluginId,
304
- contributionId: toolDescriptor.id,
305
- input,
306
- context,
307
- },
308
- { timeoutMs: contributionTimeoutMs },
309
- ),
310
- };
311
- api.registerTool(tool);
312
- }
313
-
314
- for (const commandDescriptor of descriptor.contributions.commands) {
315
- api.registerCommand({
316
- name: commandDescriptor.name,
317
- description: commandDescriptor.description,
318
- handler: async (input: string) =>
319
- await sandbox.call<string>(
320
- "executeCommand",
321
- {
322
- pluginId: descriptor.pluginId,
323
- contributionId: commandDescriptor.id,
324
- input,
325
- },
326
- { timeoutMs: contributionTimeoutMs },
327
- ),
328
- });
329
- }
330
-
331
- for (const shortcutDescriptor of descriptor.contributions.shortcuts) {
332
- api.registerShortcut({
333
- name: shortcutDescriptor.name,
334
- value: shortcutDescriptor.value ?? "",
335
- description: shortcutDescriptor.description,
336
- });
337
- }
338
-
339
- for (const flagDescriptor of descriptor.contributions.flags) {
340
- api.registerFlag({
341
- name: flagDescriptor.name,
342
- description: flagDescriptor.description,
343
- defaultValue: flagDescriptor.defaultValue,
344
- });
345
- }
346
-
347
- for (const rendererDescriptor of descriptor.contributions
348
- .messageRenderers) {
349
- api.registerMessageRenderer({
350
- name: rendererDescriptor.name,
351
- render: () =>
352
- `[sandbox renderer ${rendererDescriptor.name} requires async bridge]`,
353
- });
354
- }
355
-
356
- for (const providerDescriptor of descriptor.contributions.providers) {
357
- api.registerProvider({
358
- name: providerDescriptor.name,
359
- description: providerDescriptor.description,
360
- metadata: providerDescriptor.metadata,
361
- });
362
- }
191
+ registerTools(api, sandbox, descriptor, contributionTimeoutMs);
192
+ registerCommands(api, sandbox, descriptor, contributionTimeoutMs);
193
+ registerSimpleContributions(api, descriptor);
363
194
  },
364
195
  };
365
196
 
366
- if (hasHookStage(extension, "input")) {
367
- extension.onInput = async (payload: unknown) =>
368
- await sandbox.call(
369
- "invokeHook",
370
- { pluginId: descriptor.pluginId, hookName: "onInput", payload },
371
- { timeoutMs: hookTimeoutMs },
372
- );
373
- }
374
- if (hasHookStage(extension, "session_start")) {
375
- extension.onSessionStart = async (payload: unknown) =>
376
- await sandbox.call(
377
- "invokeHook",
378
- {
379
- pluginId: descriptor.pluginId,
380
- hookName: "onSessionStart",
381
- payload,
382
- },
383
- { timeoutMs: hookTimeoutMs },
384
- );
385
- }
386
- if (hasHookStage(extension, "before_agent_start")) {
387
- extension.onBeforeAgentStart = async (payload: unknown) =>
388
- await sandbox.call(
389
- "invokeHook",
390
- {
391
- pluginId: descriptor.pluginId,
392
- hookName: "onBeforeAgentStart",
393
- payload,
394
- },
395
- { timeoutMs: hookTimeoutMs },
396
- );
397
- }
398
- if (hasHookStage(extension, "tool_call_before")) {
399
- extension.onToolCall = async (payload: unknown) =>
400
- await sandbox.call(
401
- "invokeHook",
402
- { pluginId: descriptor.pluginId, hookName: "onToolCall", payload },
403
- { timeoutMs: hookTimeoutMs },
404
- );
405
- }
406
- if (hasHookStage(extension, "tool_call_after")) {
407
- extension.onToolResult = async (payload: unknown) =>
408
- await sandbox.call(
409
- "invokeHook",
410
- {
411
- pluginId: descriptor.pluginId,
412
- hookName: "onToolResult",
413
- payload,
414
- },
415
- { timeoutMs: hookTimeoutMs },
416
- );
417
- }
418
- if (hasHookStage(extension, "turn_end")) {
419
- extension.onAgentEnd = async (payload: unknown) =>
420
- await sandbox.call(
421
- "invokeHook",
422
- { pluginId: descriptor.pluginId, hookName: "onAgentEnd", payload },
423
- { timeoutMs: hookTimeoutMs },
424
- );
425
- }
426
- if (hasHookStage(extension, "session_shutdown")) {
427
- extension.onSessionShutdown = async (payload: unknown) =>
428
- await sandbox.call(
429
- "invokeHook",
430
- {
431
- pluginId: descriptor.pluginId,
432
- hookName: "onSessionShutdown",
433
- payload,
434
- },
435
- { timeoutMs: hookTimeoutMs },
436
- );
437
- }
438
- if (hasHookStage(extension, "runtime_event")) {
439
- extension.onRuntimeEvent = async (payload: unknown) => {
440
- await sandbox.call(
441
- "invokeHook",
442
- {
443
- pluginId: descriptor.pluginId,
444
- hookName: "onRuntimeEvent",
445
- payload,
446
- },
447
- { timeoutMs: hookTimeoutMs },
448
- );
449
- };
450
- }
451
- if (hasHookStage(extension, "error")) {
452
- extension.onError = async (payload: unknown) => {
453
- await sandbox.call(
454
- "invokeHook",
455
- { pluginId: descriptor.pluginId, hookName: "onError", payload },
456
- { timeoutMs: hookTimeoutMs },
457
- );
458
- };
459
- }
197
+ bindHooks(extension, sandbox, descriptor.pluginId, hookTimeoutMs);
460
198
 
461
199
  return extension;
462
200
  },
@@ -469,3 +207,135 @@ export async function loadSandboxedPlugins(
469
207
  },
470
208
  };
471
209
  }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Contribution registration helpers
213
+ // ---------------------------------------------------------------------------
214
+
215
+ function registerTools(
216
+ api: AgentExtensionApi,
217
+ sandbox: SubprocessSandbox,
218
+ descriptor: SandboxedPluginDescriptor,
219
+ timeoutMs: number,
220
+ ): void {
221
+ for (const td of descriptor.contributions.tools) {
222
+ const tool: Tool = {
223
+ name: td.name,
224
+ description: td.description ?? "",
225
+ inputSchema: (td.inputSchema ?? {
226
+ type: "object",
227
+ properties: {},
228
+ }) as Tool["inputSchema"],
229
+ timeoutMs: td.timeoutMs,
230
+ retryable: td.retryable,
231
+ execute: async (input: unknown, context: unknown) =>
232
+ await sandbox.call(
233
+ "executeTool",
234
+ {
235
+ pluginId: descriptor.pluginId,
236
+ contributionId: td.id,
237
+ input,
238
+ context,
239
+ },
240
+ { timeoutMs },
241
+ ),
242
+ };
243
+ api.registerTool(tool);
244
+ }
245
+ }
246
+
247
+ function registerCommands(
248
+ api: AgentExtensionApi,
249
+ sandbox: SubprocessSandbox,
250
+ descriptor: SandboxedPluginDescriptor,
251
+ timeoutMs: number,
252
+ ): void {
253
+ for (const cd of descriptor.contributions.commands) {
254
+ api.registerCommand({
255
+ name: cd.name,
256
+ description: cd.description,
257
+ handler: async (input: string) =>
258
+ await sandbox.call<string>(
259
+ "executeCommand",
260
+ {
261
+ pluginId: descriptor.pluginId,
262
+ contributionId: cd.id,
263
+ input,
264
+ },
265
+ { timeoutMs },
266
+ ),
267
+ });
268
+ }
269
+ }
270
+
271
+ function registerSimpleContributions(
272
+ api: AgentExtensionApi,
273
+ descriptor: SandboxedPluginDescriptor,
274
+ ): void {
275
+ for (const sd of descriptor.contributions.shortcuts) {
276
+ api.registerShortcut({
277
+ name: sd.name,
278
+ value: sd.value ?? "",
279
+ description: sd.description,
280
+ });
281
+ }
282
+
283
+ for (const fd of descriptor.contributions.flags) {
284
+ api.registerFlag({
285
+ name: fd.name,
286
+ description: fd.description,
287
+ defaultValue: fd.defaultValue,
288
+ });
289
+ }
290
+
291
+ for (const rd of descriptor.contributions.messageRenderers) {
292
+ api.registerMessageRenderer({
293
+ name: rd.name,
294
+ render: () => `[sandbox renderer ${rd.name} requires async bridge]`,
295
+ });
296
+ }
297
+
298
+ for (const pd of descriptor.contributions.providers) {
299
+ api.registerProvider({
300
+ name: pd.name,
301
+ description: pd.description,
302
+ metadata: pd.metadata,
303
+ });
304
+ }
305
+ }
306
+
307
+ function makeHookHandler(
308
+ sandbox: SubprocessSandbox,
309
+ pluginId: string,
310
+ hookName: string,
311
+ timeoutMs: number,
312
+ ): (payload: unknown) => Promise<unknown> {
313
+ return async (payload: unknown) =>
314
+ await sandbox.call(
315
+ "invokeHook",
316
+ { pluginId, hookName, payload },
317
+ { timeoutMs },
318
+ );
319
+ }
320
+
321
+ function bindHooks(
322
+ extension: AgentExtension,
323
+ sandbox: SubprocessSandbox,
324
+ pluginId: string,
325
+ hookTimeoutMs: number,
326
+ ): void {
327
+ for (const { stage, extensionKey, sandboxHookName } of HOOK_BINDINGS) {
328
+ if (hasHookStage(extension, stage)) {
329
+ const handler = makeHookHandler(
330
+ sandbox,
331
+ pluginId,
332
+ sandboxHookName,
333
+ hookTimeoutMs,
334
+ );
335
+ // Each hook property on AgentExtension accepts (payload: unknown) => Promise<unknown>.
336
+ // TypeScript cannot narrow a union of optional callback keys via computed access,
337
+ // so we use Object.assign to set the property safely.
338
+ Object.assign(extension, { [extensionKey]: handler });
339
+ }
340
+ }
341
+ }
package/src/index.node.ts CHANGED
@@ -207,6 +207,10 @@ export {
207
207
  SqliteRpcSessionBackend,
208
208
  type SqliteRpcSessionBackendOptions,
209
209
  } from "./session/sqlite-rpc-session-backend";
210
+ export {
211
+ accumulateUsageTotals,
212
+ createInitialAccumulatedUsage,
213
+ } from "./session/utils/usage";
210
214
  export type {
211
215
  WorkspaceManager,
212
216
  WorkspaceManagerEvent,
@@ -57,7 +57,7 @@ describe("createHookConfigFileHooks", () => {
57
57
  it("executes extensionless legacy hook files via bash fallback", async () => {
58
58
  const { workspace } = await createWorkspaceWithHook(
59
59
  "PreToolUse",
60
- 'echo \'HOOK_CONTROL\t{"cancel":true,"context":"legacy-ok"}\'\n',
60
+ 'echo \'HOOK_CONTROL\t{"cancel":true,"context":"legacy-ok"}\'\nexit 0\n',
61
61
  );
62
62
  try {
63
63
  const hooks = createHookConfigFileHooks({
@@ -215,13 +215,17 @@ async function writeToChildStdin(
215
215
 
216
216
  await new Promise<void>((resolve, reject) => {
217
217
  let settled = false;
218
+ const cleanup = () => {
219
+ stdin.off("error", onError);
220
+ stdin.off("finish", onFinish);
221
+ child.off("close", onChildClose);
222
+ };
218
223
  const finish = (error?: Error | null) => {
219
224
  if (settled) {
220
225
  return;
221
226
  }
222
227
  settled = true;
223
- stdin.off("error", onError);
224
- stdin.off("close", onClose);
228
+ cleanup();
225
229
  if (error) {
226
230
  const code = (error as Error & { code?: string }).code;
227
231
  if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
@@ -234,10 +238,16 @@ async function writeToChildStdin(
234
238
  resolve();
235
239
  };
236
240
  const onError = (error: Error) => finish(error);
237
- const onClose = () => finish();
241
+ const onFinish = () => finish();
242
+ const onChildClose = () => finish();
238
243
  stdin.on("error", onError);
239
- stdin.once("close", onClose);
240
- stdin.end(body, (error?: Error | null) => finish(error));
244
+ stdin.once("finish", onFinish);
245
+ child.once("close", onChildClose);
246
+ try {
247
+ stdin.end(body);
248
+ } catch (error) {
249
+ finish(error as Error);
250
+ }
241
251
  });
242
252
  }
243
253
 
@@ -270,10 +280,10 @@ async function runHookCommand(
270
280
  });
271
281
 
272
282
  const body = JSON.stringify(payload);
283
+ await Promise.race([spawned, childError]);
273
284
  await writeToChildStdin(child, body);
274
285
 
275
286
  if (options.detached) {
276
- await Promise.race([spawned, childError]);
277
287
  child.unref();
278
288
  return;
279
289
  }