@clinebot/core 0.0.5 → 0.0.7

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 (57) hide show
  1. package/dist/agents/hooks-config-loader.d.ts +1 -0
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.node.d.ts +2 -0
  4. package/dist/index.node.js +134 -107
  5. package/dist/runtime/session-runtime.d.ts +3 -1
  6. package/dist/session/default-session-manager.d.ts +4 -0
  7. package/dist/session/rpc-spawn-lease.d.ts +7 -0
  8. package/dist/session/session-host.d.ts +2 -0
  9. package/dist/session/session-manager.d.ts +1 -0
  10. package/dist/storage/provider-settings-legacy-migration.d.ts +25 -0
  11. package/dist/telemetry/ITelemetryAdapter.d.ts +54 -0
  12. package/dist/telemetry/LoggerTelemetryAdapter.d.ts +21 -0
  13. package/dist/telemetry/OpenTelemetryAdapter.d.ts +43 -0
  14. package/dist/telemetry/OpenTelemetryProvider.d.ts +41 -0
  15. package/dist/telemetry/TelemetryService.d.ts +34 -0
  16. package/dist/telemetry/opentelemetry.d.ts +3 -0
  17. package/dist/telemetry/opentelemetry.js +27 -0
  18. package/dist/tools/schemas.d.ts +8 -8
  19. package/dist/types/config.d.ts +2 -1
  20. package/dist/types/events.d.ts +1 -1
  21. package/package.json +16 -3
  22. package/src/agents/hooks-config-loader.ts +21 -1
  23. package/src/index.node.ts +7 -0
  24. package/src/index.ts +16 -0
  25. package/src/input/file-indexer.test.ts +40 -0
  26. package/src/input/file-indexer.ts +21 -0
  27. package/src/runtime/hook-file-hooks.test.ts +98 -1
  28. package/src/runtime/hook-file-hooks.ts +93 -11
  29. package/src/runtime/runtime-builder.test.ts +20 -0
  30. package/src/runtime/runtime-builder.ts +1 -0
  31. package/src/runtime/session-runtime.ts +3 -1
  32. package/src/session/default-session-manager.test.ts +72 -0
  33. package/src/session/default-session-manager.ts +59 -1
  34. package/src/session/rpc-spawn-lease.test.ts +49 -0
  35. package/src/session/rpc-spawn-lease.ts +122 -0
  36. package/src/session/session-graph.ts +2 -0
  37. package/src/session/session-host.ts +14 -1
  38. package/src/session/session-manager.ts +1 -0
  39. package/src/storage/provider-settings-legacy-migration.test.ts +133 -1
  40. package/src/storage/provider-settings-legacy-migration.ts +60 -8
  41. package/src/telemetry/ITelemetryAdapter.ts +94 -0
  42. package/src/telemetry/LoggerTelemetryAdapter.test.ts +42 -0
  43. package/src/telemetry/LoggerTelemetryAdapter.ts +114 -0
  44. package/src/telemetry/OpenTelemetryAdapter.test.ts +157 -0
  45. package/src/telemetry/OpenTelemetryAdapter.ts +348 -0
  46. package/src/telemetry/OpenTelemetryProvider.test.ts +113 -0
  47. package/src/telemetry/OpenTelemetryProvider.ts +322 -0
  48. package/src/telemetry/TelemetryService.test.ts +134 -0
  49. package/src/telemetry/TelemetryService.ts +141 -0
  50. package/src/telemetry/opentelemetry.ts +20 -0
  51. package/src/tools/definitions.test.ts +82 -29
  52. package/src/tools/definitions.ts +41 -32
  53. package/src/tools/executors/editor.test.ts +35 -0
  54. package/src/tools/executors/editor.ts +33 -46
  55. package/src/tools/schemas.ts +34 -35
  56. package/src/types/config.ts +2 -0
  57. package/src/types/events.ts +6 -1
@@ -23,6 +23,12 @@ export declare const ReadFilesInputUnionSchema: z.ZodUnion<readonly [z.ZodObject
23
23
  export declare const SearchCodebaseInputSchema: z.ZodObject<{
24
24
  queries: z.ZodArray<z.ZodString>;
25
25
  }, z.core.$strip>;
26
+ /**
27
+ * Union schema for search_codebase tool input, allowing either a single string, an array of strings, or the full object schema
28
+ */
29
+ export declare const SearchCodebaseUnionInputSchema: z.ZodUnion<readonly [z.ZodObject<{
30
+ queries: z.ZodArray<z.ZodString>;
31
+ }, z.core.$strip>, z.ZodArray<z.ZodString>, z.ZodString]>;
26
32
  /**
27
33
  * Schema for run_commands tool input
28
34
  */
@@ -55,15 +61,9 @@ export declare const FetchWebContentInputSchema: z.ZodObject<{
55
61
  * Schema for editor tool input
56
62
  */
57
63
  export declare const EditFileInputSchema: z.ZodObject<{
58
- command: z.ZodEnum<{
59
- create: "create";
60
- str_replace: "str_replace";
61
- insert: "insert";
62
- }>;
63
64
  path: z.ZodString;
64
- file_text: z.ZodOptional<z.ZodNullable<z.ZodString>>;
65
- old_str: z.ZodOptional<z.ZodNullable<z.ZodString>>;
66
- new_str: z.ZodOptional<z.ZodNullable<z.ZodString>>;
65
+ old_text: z.ZodOptional<z.ZodNullable<z.ZodString>>;
66
+ new_text: z.ZodString;
67
67
  insert_line: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
68
68
  }, z.core.$strip>;
69
69
  /**
@@ -1,6 +1,6 @@
1
1
  import type { AgentConfig, AgentHooks, ConsecutiveMistakeLimitContext, ConsecutiveMistakeLimitDecision, HookErrorMode, TeamEvent, Tool } from "@clinebot/agents";
2
2
  import type { providers as LlmsProviders } from "@clinebot/llms";
3
- import type { AgentMode, BasicLogger, SessionExecutionConfig, SessionPromptConfig, SessionWorkspaceConfig } from "@clinebot/shared";
3
+ import type { AgentMode, BasicLogger, ITelemetryService, SessionExecutionConfig, SessionPromptConfig, SessionWorkspaceConfig } from "@clinebot/shared";
4
4
  import type { ToolRoutingRule } from "../tools/model-tool-routing.js";
5
5
  export type CoreAgentMode = AgentMode;
6
6
  export interface CoreModelConfig {
@@ -35,6 +35,7 @@ export interface CoreSessionConfig extends CoreModelConfig, CoreRuntimeFeatures,
35
35
  hooks?: AgentHooks;
36
36
  hookErrorMode?: HookErrorMode;
37
37
  logger?: BasicLogger;
38
+ telemetry?: ITelemetryService;
38
39
  extraTools?: Tool[];
39
40
  pluginPaths?: string[];
40
41
  extensions?: AgentConfig["extensions"];
@@ -11,7 +11,7 @@ export interface SessionEndedEvent {
11
11
  }
12
12
  export interface SessionToolEvent {
13
13
  sessionId: string;
14
- hookEventName: "tool_call" | "tool_result" | "agent_end" | "session_shutdown";
14
+ hookEventName: "tool_call" | "tool_result" | "agent_end" | "agent_error" | "session_shutdown";
15
15
  agentId?: string;
16
16
  conversationId?: string;
17
17
  parentAgentId?: string;
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "@clinebot/core",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "main": "./dist/index.node.js",
5
5
  "dependencies": {
6
- "@clinebot/agents": "0.0.5",
7
- "@clinebot/llms": "0.0.5",
6
+ "@clinebot/agents": "0.0.7",
7
+ "@clinebot/llms": "0.0.7",
8
+ "@opentelemetry/api": "^1.9.0",
9
+ "@opentelemetry/api-logs": "^0.56.0",
10
+ "@opentelemetry/exporter-logs-otlp-http": "^0.56.0",
11
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.56.0",
12
+ "@opentelemetry/resources": "^1.30.1",
13
+ "@opentelemetry/sdk-logs": "^0.56.0",
14
+ "@opentelemetry/sdk-metrics": "^1.30.1",
15
+ "@opentelemetry/semantic-conventions": "^1.37.0",
8
16
  "better-sqlite3": "^11.10.0",
9
17
  "nanoid": "^5.1.7",
10
18
  "simple-git": "^3.32.3",
@@ -21,6 +29,11 @@
21
29
  "development": "./src/index.node.ts",
22
30
  "types": "./dist/index.node.d.ts",
23
31
  "import": "./dist/index.node.js"
32
+ },
33
+ "./telemetry/opentelemetry": {
34
+ "development": "./src/telemetry/opentelemetry.ts",
35
+ "types": "./dist/telemetry/opentelemetry.d.ts",
36
+ "import": "./dist/telemetry/opentelemetry.js"
24
37
  }
25
38
  },
26
39
  "description": "State-aware orchestration for Cline Agent runtimes",
@@ -20,6 +20,7 @@ export enum HookConfigFileName {
20
20
  TaskResume = "TaskResume",
21
21
  TaskCancel = "TaskCancel",
22
22
  TaskComplete = "TaskComplete",
23
+ TaskError = "TaskError",
23
24
  PreToolUse = "PreToolUse",
24
25
  PostToolUse = "PostToolUse",
25
26
  UserPromptSubmit = "UserPromptSubmit",
@@ -34,6 +35,7 @@ export const HOOK_CONFIG_FILE_EVENT_MAP: Readonly<
34
35
  [HookConfigFileName.TaskResume]: "agent_resume",
35
36
  [HookConfigFileName.TaskCancel]: "agent_abort",
36
37
  [HookConfigFileName.TaskComplete]: "agent_end",
38
+ [HookConfigFileName.TaskError]: "agent_error",
37
39
  [HookConfigFileName.PreToolUse]: "tool_call",
38
40
  [HookConfigFileName.PostToolUse]: "tool_result",
39
41
  [HookConfigFileName.UserPromptSubmit]: "prompt_submit",
@@ -45,10 +47,28 @@ const HOOK_CONFIG_FILE_LOOKUP = new Map<string, HookConfigFileName>(
45
47
  Object.values(HookConfigFileName).map((name) => [name.toLowerCase(), name]),
46
48
  );
47
49
 
50
+ const SUPPORTED_HOOK_FILE_EXTENSIONS = new Set([
51
+ "",
52
+ ".sh",
53
+ ".bash",
54
+ ".zsh",
55
+ ".js",
56
+ ".mjs",
57
+ ".cjs",
58
+ ".ts",
59
+ ".mts",
60
+ ".cts",
61
+ ".py",
62
+ ]);
63
+
48
64
  export function toHookConfigFileName(
49
65
  fileName: string,
50
66
  ): HookConfigFileName | undefined {
51
- const key = basename(fileName, extname(fileName)).trim().toLowerCase();
67
+ const extension = extname(fileName).toLowerCase();
68
+ if (!SUPPORTED_HOOK_FILE_EXTENSIONS.has(extension)) {
69
+ return undefined;
70
+ }
71
+ const key = basename(fileName, extension).trim().toLowerCase();
52
72
  return HOOK_CONFIG_FILE_LOOKUP.get(key);
53
73
  }
54
74
 
package/src/index.node.ts CHANGED
@@ -100,6 +100,9 @@ export {
100
100
  OCI_HEADER_OPC_REQUEST_ID,
101
101
  refreshOcaToken,
102
102
  } from "./auth/oca";
103
+ export async function loadOpenTelemetryAdapter() {
104
+ return import("./telemetry/opentelemetry.js");
105
+ }
103
106
  export { startLocalOAuthServer } from "./auth/server";
104
107
  export type {
105
108
  OAuthCredentials,
@@ -167,6 +170,10 @@ export {
167
170
  } from "./runtime/workflows";
168
171
  export { DefaultSessionManager } from "./session/default-session-manager";
169
172
  export { RpcCoreSessionService } from "./session/rpc-session-service";
173
+ export {
174
+ type RpcSpawnLease,
175
+ tryAcquireRpcSpawnLease,
176
+ } from "./session/rpc-spawn-lease";
170
177
  export {
171
178
  deriveSubsessionStatus,
172
179
  makeSubSessionId,
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export type {
19
19
  BasicLogger,
20
20
  ConnectorHookEvent,
21
21
  HookSessionContext,
22
+ ITelemetryService,
22
23
  RpcAddProviderActionRequest,
23
24
  RpcChatMessage,
24
25
  RpcChatRunTurnRequest,
@@ -38,6 +39,12 @@ export type {
38
39
  RpcSaveProviderSettingsActionRequest,
39
40
  SessionLineage,
40
41
  TeamProgressProjectionEvent,
42
+ TelemetryArray,
43
+ TelemetryMetadata,
44
+ TelemetryObject,
45
+ TelemetryPrimitive,
46
+ TelemetryProperties,
47
+ TelemetryValue,
41
48
  ToolPolicy,
42
49
  } from "@clinebot/shared";
43
50
  export {
@@ -116,6 +123,15 @@ export {
116
123
  buildTeamProgressSummary,
117
124
  toTeamProgressLifecycleEvent,
118
125
  } from "./team";
126
+ export type { ITelemetryAdapter } from "./telemetry/ITelemetryAdapter";
127
+ export {
128
+ LoggerTelemetryAdapter,
129
+ type LoggerTelemetryAdapterOptions,
130
+ } from "./telemetry/LoggerTelemetryAdapter";
131
+ export {
132
+ TelemetryService,
133
+ type TelemetryServiceOptions,
134
+ } from "./telemetry/TelemetryService";
119
135
  export {
120
136
  ALL_DEFAULT_TOOL_NAMES,
121
137
  type AskQuestionExecutor,
@@ -84,4 +84,44 @@ describe("file indexer", () => {
84
84
  await rm(cwd, { recursive: true, force: true });
85
85
  }
86
86
  });
87
+
88
+ it("evicts stale workspace indexes after 10 minutes when multiple workspaces exist", async () => {
89
+ vi.useFakeTimers();
90
+ const firstWorkspace = await createTempWorkspace();
91
+ const secondWorkspace = await createTempWorkspace();
92
+ try {
93
+ await writeFile(
94
+ path.join(firstWorkspace, "first.ts"),
95
+ "export const first = 1\n",
96
+ "utf8",
97
+ );
98
+ await writeFile(
99
+ path.join(secondWorkspace, "second.ts"),
100
+ "export const second = 2\n",
101
+ "utf8",
102
+ );
103
+
104
+ const firstIndex = await getFileIndex(firstWorkspace, { ttlMs: 60_000 });
105
+ expect(firstIndex.has("first.ts")).toBe(true);
106
+
107
+ await getFileIndex(secondWorkspace, { ttlMs: 60_000 });
108
+ vi.advanceTimersByTime(10 * 60_000 + 1);
109
+
110
+ await getFileIndex(secondWorkspace, { ttlMs: 60_000 });
111
+ await writeFile(
112
+ path.join(firstWorkspace, "later.ts"),
113
+ "export const later = 3\n",
114
+ "utf8",
115
+ );
116
+
117
+ const rebuiltFirstIndex = await getFileIndex(firstWorkspace, {
118
+ ttlMs: 60_000,
119
+ });
120
+ expect(rebuiltFirstIndex.has("later.ts")).toBe(true);
121
+ } finally {
122
+ vi.useRealTimers();
123
+ await rm(firstWorkspace, { recursive: true, force: true });
124
+ await rm(secondWorkspace, { recursive: true, force: true });
125
+ }
126
+ });
87
127
  });
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { isMainThread, parentPort, Worker } from "node:worker_threads";
5
5
 
6
6
  const DEFAULT_INDEX_TTL_MS = 15_000;
7
+ const STALE_CACHE_EVICTION_MS = 10 * 60_000;
7
8
  const WORKER_INDEX_REQUEST_TIMEOUT_MS = 1_000;
8
9
  const DEFAULT_EXCLUDE_DIRS = new Set([
9
10
  ".git",
@@ -21,6 +22,7 @@ const DEFAULT_EXCLUDE_DIRS = new Set([
21
22
  interface CacheEntry {
22
23
  files: Set<string>;
23
24
  lastBuiltAt: number;
25
+ lastAccessedAt: number;
24
26
  pending: Promise<Set<string>> | null;
25
27
  }
26
28
 
@@ -43,6 +45,20 @@ interface IndexResponseMessage {
43
45
 
44
46
  const CACHE = new Map<string, CacheEntry>();
45
47
 
48
+ function pruneStaleCacheEntries(now: number): void {
49
+ if (CACHE.size <= 1) {
50
+ return;
51
+ }
52
+ for (const [cwd, entry] of CACHE.entries()) {
53
+ if (entry.pending) {
54
+ continue;
55
+ }
56
+ if (now - entry.lastAccessedAt > STALE_CACHE_EVICTION_MS) {
57
+ CACHE.delete(cwd);
58
+ }
59
+ }
60
+ }
61
+
46
62
  function toPosixRelative(cwd: string, absolutePath: string): string {
47
63
  return path.relative(cwd, absolutePath).split(path.sep).join("/");
48
64
  }
@@ -265,6 +281,7 @@ export async function getFileIndex(
265
281
  ): Promise<Set<string>> {
266
282
  const ttlMs = options.ttlMs ?? DEFAULT_INDEX_TTL_MS;
267
283
  const now = Date.now();
284
+ pruneStaleCacheEntries(now);
268
285
  const existing = CACHE.get(cwd);
269
286
 
270
287
  if (
@@ -273,10 +290,12 @@ export async function getFileIndex(
273
290
  now - existing.lastBuiltAt <= ttlMs &&
274
291
  existing.files.size > 0
275
292
  ) {
293
+ existing.lastAccessedAt = now;
276
294
  return existing.files;
277
295
  }
278
296
 
279
297
  if (existing?.pending) {
298
+ existing.lastAccessedAt = now;
280
299
  return existing.pending;
281
300
  }
282
301
 
@@ -284,6 +303,7 @@ export async function getFileIndex(
284
303
  CACHE.set(cwd, {
285
304
  files,
286
305
  lastBuiltAt: Date.now(),
306
+ lastAccessedAt: Date.now(),
287
307
  pending: null,
288
308
  });
289
309
  return files;
@@ -292,6 +312,7 @@ export async function getFileIndex(
292
312
  CACHE.set(cwd, {
293
313
  files: existing?.files ?? new Set<string>(),
294
314
  lastBuiltAt: existing?.lastBuiltAt ?? 0,
315
+ lastAccessedAt: now,
295
316
  pending,
296
317
  });
297
318
 
@@ -1,9 +1,30 @@
1
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
5
  import { createHookConfigFileHooks } from "./hook-file-hooks";
6
6
 
7
+ async function waitForFile(
8
+ filePath: string,
9
+ timeoutMs = 1500,
10
+ ): Promise<string> {
11
+ const started = Date.now();
12
+ for (;;) {
13
+ try {
14
+ return await readFile(filePath, "utf8");
15
+ } catch (error) {
16
+ const code =
17
+ error && typeof error === "object" && "code" in error
18
+ ? String((error as { code?: unknown }).code)
19
+ : undefined;
20
+ if (code !== "ENOENT" || Date.now() - started >= timeoutMs) {
21
+ throw error;
22
+ }
23
+ await new Promise((resolve) => setTimeout(resolve, 25));
24
+ }
25
+ }
26
+ }
27
+
7
28
  async function createWorkspaceWithHook(
8
29
  fileName: string,
9
30
  body: string,
@@ -17,6 +38,22 @@ async function createWorkspaceWithHook(
17
38
  }
18
39
 
19
40
  describe("createHookConfigFileHooks", () => {
41
+ it("ignores example hook files", async () => {
42
+ const { workspace } = await createWorkspaceWithHook(
43
+ "PreToolUse.example",
44
+ 'echo \'HOOK_CONTROL\t{"cancel":true,"context":"should-not-run"}\'\n',
45
+ );
46
+ try {
47
+ const hooks = createHookConfigFileHooks({
48
+ cwd: workspace,
49
+ workspacePath: workspace,
50
+ });
51
+ expect(hooks).toBeUndefined();
52
+ } finally {
53
+ await rm(workspace, { recursive: true, force: true });
54
+ }
55
+ });
56
+
20
57
  it("executes extensionless legacy hook files via bash fallback", async () => {
21
58
  const { workspace } = await createWorkspaceWithHook(
22
59
  "PreToolUse",
@@ -103,4 +140,64 @@ describe("createHookConfigFileHooks", () => {
103
140
  await rm(workspace, { recursive: true, force: true });
104
141
  }
105
142
  });
143
+
144
+ it("executes python hook files", async () => {
145
+ const { workspace } = await createWorkspaceWithHook(
146
+ "PreToolUse.py",
147
+ 'print(\'HOOK_CONTROL\\t{"cancel": false, "context": "python-ok"}\')\n',
148
+ );
149
+ try {
150
+ const hooks = createHookConfigFileHooks({
151
+ cwd: workspace,
152
+ workspacePath: workspace,
153
+ });
154
+ expect(hooks?.onToolCallStart).toBeTypeOf("function");
155
+ const control = await hooks?.onToolCallStart?.({
156
+ agentId: "agent_1",
157
+ conversationId: "conv_1",
158
+ parentAgentId: null,
159
+ iteration: 1,
160
+ call: {
161
+ id: "call_1",
162
+ name: "read_file",
163
+ input: { path: "README.md" },
164
+ },
165
+ });
166
+ expect(control).toMatchObject({
167
+ cancel: false,
168
+ context: "python-ok",
169
+ });
170
+ } finally {
171
+ await rm(workspace, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ it("maps TaskError hook files to agent_error stop events", async () => {
176
+ const outputPath = join(tmpdir(), `hooks-task-error-${Date.now()}.json`);
177
+ const { workspace } = await createWorkspaceWithHook(
178
+ "TaskError.js",
179
+ `let data='';process.stdin.on('data',c=>data+=c);process.stdin.on('end',()=>{require('node:fs').writeFileSync(${JSON.stringify(outputPath)}, data);});\n`,
180
+ );
181
+ try {
182
+ const hooks = createHookConfigFileHooks({
183
+ cwd: workspace,
184
+ workspacePath: workspace,
185
+ });
186
+ await hooks?.onStopError?.({
187
+ agentId: "agent_1",
188
+ conversationId: "conv_1",
189
+ parentAgentId: null,
190
+ iteration: 3,
191
+ error: new Error("401 unauthorized"),
192
+ });
193
+ const payload = JSON.parse(await waitForFile(outputPath)) as {
194
+ hookName: string;
195
+ error?: { message?: string };
196
+ };
197
+ expect(payload.hookName).toBe("agent_error");
198
+ expect(payload.error?.message).toBe("401 unauthorized");
199
+ } finally {
200
+ await rm(workspace, { recursive: true, force: true });
201
+ }
202
+ });
106
203
  });
@@ -30,6 +30,9 @@ type AgentHookToolCallEndContext = Parameters<
30
30
  type AgentHookTurnEndContext = Parameters<
31
31
  NonNullable<AgentHooks["onTurnEnd"]>
32
32
  >[0];
33
+ type AgentHookStopErrorContext = Parameters<
34
+ NonNullable<AgentHooks["onStopError"]>
35
+ >[0];
33
36
  type AgentHookSessionShutdownContext = Parameters<
34
37
  NonNullable<AgentHooks["onSessionShutdown"]>
35
38
  >[0];
@@ -201,6 +204,42 @@ function parseHookStdout(stdout: string): {
201
204
  }
202
205
  }
203
206
 
207
+ async function writeToChildStdin(
208
+ child: ReturnType<typeof spawn>,
209
+ body: string,
210
+ ): Promise<void> {
211
+ const stdin = child.stdin;
212
+ if (!stdin) {
213
+ throw new Error("hook command failed to create stdin");
214
+ }
215
+
216
+ await new Promise<void>((resolve, reject) => {
217
+ const onError = (error: Error) => {
218
+ stdin.off("error", onError);
219
+ const code = (error as Error & { code?: string }).code;
220
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
221
+ resolve();
222
+ return;
223
+ }
224
+ reject(error);
225
+ };
226
+ stdin.once("error", onError);
227
+ stdin.end(body, (error?: Error | null) => {
228
+ stdin.off("error", onError);
229
+ if (error) {
230
+ const code = (error as Error & { code?: string }).code;
231
+ if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") {
232
+ resolve();
233
+ return;
234
+ }
235
+ reject(error);
236
+ return;
237
+ }
238
+ resolve();
239
+ });
240
+ });
241
+ }
242
+
204
243
  async function runHookCommand(
205
244
  payload: HookEventPayload,
206
245
  options: {
@@ -222,19 +261,18 @@ async function runHookCommand(
222
261
  : ["pipe", "pipe", "pipe"],
223
262
  detached: options.detached,
224
263
  });
264
+ const spawned = new Promise<void>((resolve) => {
265
+ child.once("spawn", () => resolve());
266
+ });
267
+ const childError = new Promise<never>((_, reject) => {
268
+ child.once("error", (error) => reject(error));
269
+ });
225
270
 
226
271
  const body = JSON.stringify(payload);
227
- if (!child.stdin) {
228
- throw new Error("hook command failed to create stdin");
229
- }
230
- child.stdin.write(body);
231
- child.stdin.end();
272
+ await writeToChildStdin(child, body);
232
273
 
233
274
  if (options.detached) {
234
- await new Promise<void>((resolve, reject) => {
235
- child.once("error", reject);
236
- child.once("spawn", () => resolve());
237
- });
275
+ await Promise.race([spawned, childError]);
238
276
  child.unref();
239
277
  return;
240
278
  }
@@ -253,8 +291,7 @@ async function runHookCommand(
253
291
  stderr += chunk.toString();
254
292
  });
255
293
 
256
- return await new Promise<HookCommandResult>((resolve, reject) => {
257
- child.once("error", reject);
294
+ const result = new Promise<HookCommandResult>((resolve) => {
258
295
  if ((options.timeoutMs ?? 0) > 0) {
259
296
  timeoutId = setTimeout(() => {
260
297
  timedOut = true;
@@ -276,6 +313,7 @@ async function runHookCommand(
276
313
  });
277
314
  });
278
315
  });
316
+ return await Promise.race([result, childError]);
279
317
  }
280
318
 
281
319
  function parseShebangCommand(path: string): string[] | undefined {
@@ -323,6 +361,9 @@ function inferHookCommand(path: string): string[] {
323
361
  ) {
324
362
  return ["bun", "run", path];
325
363
  }
364
+ if (lowered.endsWith(".py")) {
365
+ return ["python3", path];
366
+ }
326
367
  // Default to bash for legacy hook files with no extension/shebang.
327
368
  return ["/bin/bash", path];
328
369
  }
@@ -488,6 +529,19 @@ export function createHookAuditHooks(options: {
488
529
  });
489
530
  return undefined;
490
531
  },
532
+ onStopError: async (ctx: AgentHookStopErrorContext) => {
533
+ append({
534
+ ...createPayloadBase(ctx, runtimeOptions),
535
+ hookName: "agent_error",
536
+ iteration: ctx.iteration,
537
+ error: {
538
+ name: ctx.error.name,
539
+ message: ctx.error.message,
540
+ stack: ctx.error.stack,
541
+ },
542
+ });
543
+ return undefined;
544
+ },
491
545
  onSessionShutdown: async (ctx: AgentHookSessionShutdownContext) => {
492
546
  if (isAbortReason(ctx.reason)) {
493
547
  append({
@@ -631,6 +685,30 @@ export function createHookConfigFileHooks(
631
685
  });
632
686
  };
633
687
 
688
+ const runStopError = async (
689
+ ctx: AgentHookStopErrorContext,
690
+ ): Promise<void> => {
691
+ const commandPaths = commandMap.agent_error ?? [];
692
+ if (commandPaths.length === 0) {
693
+ return;
694
+ }
695
+ runAsyncHookCommands({
696
+ commands: commandPaths,
697
+ cwd: options.cwd,
698
+ logger: options.logger,
699
+ payload: {
700
+ ...createPayloadBase(ctx, options),
701
+ hookName: "agent_error",
702
+ iteration: ctx.iteration,
703
+ error: {
704
+ name: ctx.error.name,
705
+ message: ctx.error.message,
706
+ stack: ctx.error.stack,
707
+ },
708
+ },
709
+ });
710
+ };
711
+
634
712
  const runSessionShutdown = async (
635
713
  ctx: AgentHookSessionShutdownContext,
636
714
  ): Promise<void> => {
@@ -681,6 +759,10 @@ export function createHookConfigFileHooks(
681
759
  await runTurnEnd(ctx);
682
760
  return undefined;
683
761
  },
762
+ onStopError: async (ctx: AgentHookStopErrorContext) => {
763
+ await runStopError(ctx);
764
+ return undefined;
765
+ },
684
766
  onSessionShutdown: async (ctx: AgentHookSessionShutdownContext) => {
685
767
  await runSessionShutdown(ctx);
686
768
  return undefined;
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { Tool } from "@clinebot/agents";
5
5
  import { describe, expect, it } from "vitest";
6
+ import { TelemetryService } from "../telemetry/TelemetryService";
6
7
  import { DefaultRuntimeBuilder } from "./runtime-builder";
7
8
 
8
9
  function makeSpawnTool(): Tool {
@@ -55,6 +56,25 @@ describe("DefaultRuntimeBuilder", () => {
55
56
  expect(runtime.logger).toBe(logger);
56
57
  });
57
58
 
59
+ it("forwards telemetry for downstream runtime consumers", () => {
60
+ const telemetry = new TelemetryService();
61
+ const runtime = new DefaultRuntimeBuilder().build({
62
+ config: {
63
+ providerId: "anthropic",
64
+ modelId: "claude-sonnet-4-6",
65
+ apiKey: "key",
66
+ systemPrompt: "test",
67
+ cwd: process.cwd(),
68
+ enableTools: false,
69
+ enableSpawnAgent: false,
70
+ enableAgentTeams: false,
71
+ telemetry,
72
+ },
73
+ });
74
+
75
+ expect(runtime.telemetry).toBe(telemetry);
76
+ });
77
+
58
78
  it("uses readonly preset in plan mode", () => {
59
79
  const runtime = new DefaultRuntimeBuilder().build({
60
80
  config: {
@@ -522,6 +522,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
522
522
  return {
523
523
  tools,
524
524
  logger: logger ?? config.logger,
525
+ telemetry: input.telemetry ?? config.telemetry,
525
526
  teamRuntime,
526
527
  completionGuard,
527
528
  shutdown: (reason: string) => {
@@ -5,7 +5,7 @@ import type {
5
5
  AgentTeamsRuntime,
6
6
  Tool,
7
7
  } from "@clinebot/agents";
8
- import type { BasicLogger } from "@clinebot/shared";
8
+ import type { BasicLogger, ITelemetryService } from "@clinebot/shared";
9
9
  import type { UserInstructionConfigWatcher } from "../agents";
10
10
  import type { ToolExecutors } from "../tools";
11
11
  import type { CoreSessionConfig } from "../types/config";
@@ -14,6 +14,7 @@ export interface BuiltRuntime {
14
14
  tools: Tool[];
15
15
  hooks?: AgentHooks;
16
16
  logger?: BasicLogger;
17
+ telemetry?: ITelemetryService;
17
18
  teamRuntime?: AgentTeamsRuntime;
18
19
  completionGuard?: () => string | undefined;
19
20
  shutdown: (reason: string) => Promise<void> | void;
@@ -29,6 +30,7 @@ export interface RuntimeBuilderInput {
29
30
  userInstructionWatcher?: UserInstructionConfigWatcher;
30
31
  defaultToolExecutors?: Partial<ToolExecutors>;
31
32
  logger?: BasicLogger;
33
+ telemetry?: ITelemetryService;
32
34
  }
33
35
 
34
36
  export interface RuntimeBuilder {