@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.
- package/dist/agents/hooks-config-loader.d.ts +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.node.d.ts +2 -0
- package/dist/index.node.js +134 -107
- package/dist/runtime/session-runtime.d.ts +3 -1
- package/dist/session/default-session-manager.d.ts +4 -0
- package/dist/session/rpc-spawn-lease.d.ts +7 -0
- package/dist/session/session-host.d.ts +2 -0
- package/dist/session/session-manager.d.ts +1 -0
- package/dist/storage/provider-settings-legacy-migration.d.ts +25 -0
- package/dist/telemetry/ITelemetryAdapter.d.ts +54 -0
- package/dist/telemetry/LoggerTelemetryAdapter.d.ts +21 -0
- package/dist/telemetry/OpenTelemetryAdapter.d.ts +43 -0
- package/dist/telemetry/OpenTelemetryProvider.d.ts +41 -0
- package/dist/telemetry/TelemetryService.d.ts +34 -0
- package/dist/telemetry/opentelemetry.d.ts +3 -0
- package/dist/telemetry/opentelemetry.js +27 -0
- package/dist/tools/schemas.d.ts +8 -8
- package/dist/types/config.d.ts +2 -1
- package/dist/types/events.d.ts +1 -1
- package/package.json +16 -3
- package/src/agents/hooks-config-loader.ts +21 -1
- package/src/index.node.ts +7 -0
- package/src/index.ts +16 -0
- package/src/input/file-indexer.test.ts +40 -0
- package/src/input/file-indexer.ts +21 -0
- package/src/runtime/hook-file-hooks.test.ts +98 -1
- package/src/runtime/hook-file-hooks.ts +93 -11
- package/src/runtime/runtime-builder.test.ts +20 -0
- package/src/runtime/runtime-builder.ts +1 -0
- package/src/runtime/session-runtime.ts +3 -1
- package/src/session/default-session-manager.test.ts +72 -0
- package/src/session/default-session-manager.ts +59 -1
- package/src/session/rpc-spawn-lease.test.ts +49 -0
- package/src/session/rpc-spawn-lease.ts +122 -0
- package/src/session/session-graph.ts +2 -0
- package/src/session/session-host.ts +14 -1
- package/src/session/session-manager.ts +1 -0
- package/src/storage/provider-settings-legacy-migration.test.ts +133 -1
- package/src/storage/provider-settings-legacy-migration.ts +60 -8
- package/src/telemetry/ITelemetryAdapter.ts +94 -0
- package/src/telemetry/LoggerTelemetryAdapter.test.ts +42 -0
- package/src/telemetry/LoggerTelemetryAdapter.ts +114 -0
- package/src/telemetry/OpenTelemetryAdapter.test.ts +157 -0
- package/src/telemetry/OpenTelemetryAdapter.ts +348 -0
- package/src/telemetry/OpenTelemetryProvider.test.ts +113 -0
- package/src/telemetry/OpenTelemetryProvider.ts +322 -0
- package/src/telemetry/TelemetryService.test.ts +134 -0
- package/src/telemetry/TelemetryService.ts +141 -0
- package/src/telemetry/opentelemetry.ts +20 -0
- package/src/tools/definitions.test.ts +82 -29
- package/src/tools/definitions.ts +41 -32
- package/src/tools/executors/editor.test.ts +35 -0
- package/src/tools/executors/editor.ts +33 -46
- package/src/tools/schemas.ts +34 -35
- package/src/types/config.ts +2 -0
- package/src/types/events.ts +6 -1
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
/**
|
package/dist/types/config.d.ts
CHANGED
|
@@ -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"];
|
package/dist/types/events.d.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"main": "./dist/index.node.js",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@clinebot/agents": "0.0.
|
|
7
|
-
"@clinebot/llms": "0.0.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
@@ -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 {
|