@clinebot/core 0.0.6 → 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.node.d.ts +1 -0
- package/dist/index.node.js +81 -81
- package/dist/session/rpc-spawn-lease.d.ts +7 -0
- package/dist/storage/provider-settings-legacy-migration.d.ts +25 -0
- package/dist/tools/schemas.d.ts +2 -8
- package/dist/types/events.d.ts +1 -1
- package/package.json +3 -3
- package/src/agents/hooks-config-loader.ts +2 -0
- package/src/index.node.ts +4 -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 +51 -1
- package/src/runtime/hook-file-hooks.ts +90 -11
- 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 +8 -0
- package/src/storage/provider-settings-legacy-migration.test.ts +133 -1
- package/src/storage/provider-settings-legacy-migration.ts +60 -8
- package/src/tools/definitions.test.ts +82 -29
- package/src/tools/definitions.ts +6 -4
- package/src/tools/executors/editor.test.ts +35 -0
- package/src/tools/executors/editor.ts +33 -46
- package/src/tools/schemas.ts +25 -35
- package/src/types/events.ts +6 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProviderSettings } from "../types/provider-settings";
|
|
1
2
|
import type { ProviderSettingsManager } from "./provider-settings-manager";
|
|
2
3
|
export interface MigrateLegacyProviderSettingsOptions {
|
|
3
4
|
providerSettingsManager: ProviderSettingsManager;
|
|
@@ -10,4 +11,28 @@ export interface MigrateLegacyProviderSettingsResult {
|
|
|
10
11
|
providerCount: number;
|
|
11
12
|
lastUsedProvider?: string;
|
|
12
13
|
}
|
|
14
|
+
export type LegacyClineUserInfo = {
|
|
15
|
+
idToken: string;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
refreshToken: string;
|
|
18
|
+
userInfo: {
|
|
19
|
+
id: string;
|
|
20
|
+
email: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
termsAcceptedAt: string;
|
|
23
|
+
clineBenchConsent: boolean;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
};
|
|
27
|
+
provider: string;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Resolves legacy Cline account auth data from the raw `cline:clineAccountId`
|
|
32
|
+
* secret string into the auth fields used by `ProviderSettings`.
|
|
33
|
+
*
|
|
34
|
+
* Returns `undefined` when the input is missing, empty, whitespace-only, or
|
|
35
|
+
* unparseable JSON.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveLegacyClineAuth(rawAccountData: string | undefined): ProviderSettings["auth"] | undefined;
|
|
13
38
|
export declare function migrateLegacyProviderSettings(options: MigrateLegacyProviderSettingsOptions): MigrateLegacyProviderSettingsResult;
|
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -61,15 +61,9 @@ export declare const FetchWebContentInputSchema: z.ZodObject<{
|
|
|
61
61
|
* Schema for editor tool input
|
|
62
62
|
*/
|
|
63
63
|
export declare const EditFileInputSchema: z.ZodObject<{
|
|
64
|
-
command: z.ZodEnum<{
|
|
65
|
-
create: "create";
|
|
66
|
-
str_replace: "str_replace";
|
|
67
|
-
insert: "insert";
|
|
68
|
-
}>;
|
|
69
64
|
path: z.ZodString;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
new_str: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
65
|
+
old_text: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
66
|
+
new_text: z.ZodString;
|
|
73
67
|
insert_line: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
74
68
|
}, z.core.$strip>;
|
|
75
69
|
/**
|
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,10 @@
|
|
|
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
8
|
"@opentelemetry/api": "^1.9.0",
|
|
9
9
|
"@opentelemetry/api-logs": "^0.56.0",
|
|
10
10
|
"@opentelemetry/exporter-logs-otlp-http": "^0.56.0",
|
|
@@ -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",
|
package/src/index.node.ts
CHANGED
|
@@ -170,6 +170,10 @@ export {
|
|
|
170
170
|
} from "./runtime/workflows";
|
|
171
171
|
export { DefaultSessionManager } from "./session/default-session-manager";
|
|
172
172
|
export { RpcCoreSessionService } from "./session/rpc-session-service";
|
|
173
|
+
export {
|
|
174
|
+
type RpcSpawnLease,
|
|
175
|
+
tryAcquireRpcSpawnLease,
|
|
176
|
+
} from "./session/rpc-spawn-lease";
|
|
173
177
|
export {
|
|
174
178
|
deriveSubsessionStatus,
|
|
175
179
|
makeSubSessionId,
|
|
@@ -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,
|
|
@@ -150,4 +171,33 @@ describe("createHookConfigFileHooks", () => {
|
|
|
150
171
|
await rm(workspace, { recursive: true, force: true });
|
|
151
172
|
}
|
|
152
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
|
+
});
|
|
153
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 {
|
|
@@ -491,6 +529,19 @@ export function createHookAuditHooks(options: {
|
|
|
491
529
|
});
|
|
492
530
|
return undefined;
|
|
493
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
|
+
},
|
|
494
545
|
onSessionShutdown: async (ctx: AgentHookSessionShutdownContext) => {
|
|
495
546
|
if (isAbortReason(ctx.reason)) {
|
|
496
547
|
append({
|
|
@@ -634,6 +685,30 @@ export function createHookConfigFileHooks(
|
|
|
634
685
|
});
|
|
635
686
|
};
|
|
636
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
|
+
|
|
637
712
|
const runSessionShutdown = async (
|
|
638
713
|
ctx: AgentHookSessionShutdownContext,
|
|
639
714
|
): Promise<void> => {
|
|
@@ -684,6 +759,10 @@ export function createHookConfigFileHooks(
|
|
|
684
759
|
await runTurnEnd(ctx);
|
|
685
760
|
return undefined;
|
|
686
761
|
},
|
|
762
|
+
onStopError: async (ctx: AgentHookStopErrorContext) => {
|
|
763
|
+
await runStopError(ctx);
|
|
764
|
+
return undefined;
|
|
765
|
+
},
|
|
687
766
|
onSessionShutdown: async (ctx: AgentHookSessionShutdownContext) => {
|
|
688
767
|
await runSessionShutdown(ctx);
|
|
689
768
|
return undefined;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { tryAcquireRpcSpawnLease } from "./rpc-spawn-lease";
|
|
6
|
+
|
|
7
|
+
describe("tryAcquireRpcSpawnLease", () => {
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
delete process.env.CLINE_DATA_DIR;
|
|
12
|
+
for (const dir of tempDirs.splice(0)) {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("allows only one active lease per address", () => {
|
|
18
|
+
const dataDir = mkdtempSync(path.join(os.tmpdir(), "rpc-spawn-lease-"));
|
|
19
|
+
tempDirs.push(dataDir);
|
|
20
|
+
process.env.CLINE_DATA_DIR = dataDir;
|
|
21
|
+
|
|
22
|
+
const first = tryAcquireRpcSpawnLease("127.0.0.1:4317");
|
|
23
|
+
const second = tryAcquireRpcSpawnLease("127.0.0.1:4317");
|
|
24
|
+
|
|
25
|
+
expect(first).toBeDefined();
|
|
26
|
+
expect(second).toBeUndefined();
|
|
27
|
+
|
|
28
|
+
first?.release();
|
|
29
|
+
|
|
30
|
+
const third = tryAcquireRpcSpawnLease("127.0.0.1:4317");
|
|
31
|
+
expect(third).toBeDefined();
|
|
32
|
+
third?.release();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("lets different addresses acquire independent leases", () => {
|
|
36
|
+
const dataDir = mkdtempSync(path.join(os.tmpdir(), "rpc-spawn-lease-"));
|
|
37
|
+
tempDirs.push(dataDir);
|
|
38
|
+
process.env.CLINE_DATA_DIR = dataDir;
|
|
39
|
+
|
|
40
|
+
const first = tryAcquireRpcSpawnLease("127.0.0.1:4317");
|
|
41
|
+
const second = tryAcquireRpcSpawnLease("127.0.0.1:4318");
|
|
42
|
+
|
|
43
|
+
expect(first).toBeDefined();
|
|
44
|
+
expect(second).toBeDefined();
|
|
45
|
+
|
|
46
|
+
first?.release();
|
|
47
|
+
second?.release();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { resolveSessionDataDir } from "@clinebot/shared/storage";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LEASE_TTL_MS = 15_000;
|
|
14
|
+
|
|
15
|
+
interface RpcSpawnLeaseRecord {
|
|
16
|
+
address: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RpcSpawnLease {
|
|
22
|
+
path: string;
|
|
23
|
+
release: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function encodeAddress(address: string): string {
|
|
27
|
+
return Buffer.from(address).toString("base64url");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getLeasePath(address: string): string {
|
|
31
|
+
return resolve(
|
|
32
|
+
resolveSessionDataDir(),
|
|
33
|
+
"rpc",
|
|
34
|
+
"spawn-leases",
|
|
35
|
+
`${encodeAddress(address)}.lock`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isProcessAlive(pid: number): boolean {
|
|
40
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function shouldClearLease(path: string, ttlMs: number): boolean {
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(path, "utf8");
|
|
54
|
+
const parsed = JSON.parse(raw) as Partial<RpcSpawnLeaseRecord>;
|
|
55
|
+
const createdAt = Number(parsed.createdAt ?? 0);
|
|
56
|
+
if (!Number.isFinite(createdAt) || createdAt <= 0) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() - createdAt > ttlMs) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return !isProcessAlive(Number(parsed.pid ?? 0));
|
|
63
|
+
} catch {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function tryAcquireRpcSpawnLease(
|
|
69
|
+
address: string,
|
|
70
|
+
options?: { ttlMs?: number },
|
|
71
|
+
): RpcSpawnLease | undefined {
|
|
72
|
+
const ttlMs = Math.max(1_000, options?.ttlMs ?? DEFAULT_LEASE_TTL_MS);
|
|
73
|
+
const path = getLeasePath(address);
|
|
74
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
75
|
+
|
|
76
|
+
if (existsSync(path) && shouldClearLease(path, ttlMs)) {
|
|
77
|
+
rmSync(path, { force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let fd: number | undefined;
|
|
81
|
+
try {
|
|
82
|
+
fd = openSync(path, "wx");
|
|
83
|
+
const record: RpcSpawnLeaseRecord = {
|
|
84
|
+
address,
|
|
85
|
+
pid: process.pid,
|
|
86
|
+
createdAt: Date.now(),
|
|
87
|
+
};
|
|
88
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
if (typeof fd === "number") {
|
|
91
|
+
try {
|
|
92
|
+
closeSync(fd);
|
|
93
|
+
} catch {
|
|
94
|
+
// Best effort.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let released = false;
|
|
101
|
+
return {
|
|
102
|
+
path,
|
|
103
|
+
release: () => {
|
|
104
|
+
if (released) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
released = true;
|
|
108
|
+
try {
|
|
109
|
+
if (typeof fd === "number") {
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Best effort.
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
rmSync(path, { force: true });
|
|
117
|
+
} catch {
|
|
118
|
+
// Best effort.
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -70,6 +70,8 @@ export function deriveSubsessionStatus(event: HookEventPayload): SessionStatus {
|
|
|
70
70
|
switch (event.hookName) {
|
|
71
71
|
case "agent_end":
|
|
72
72
|
return "completed";
|
|
73
|
+
case "agent_error":
|
|
74
|
+
return "failed";
|
|
73
75
|
case "session_shutdown": {
|
|
74
76
|
const reason = String(event.reason ?? "").toLowerCase();
|
|
75
77
|
if (
|
|
@@ -14,6 +14,7 @@ import { SqliteSessionStore } from "../storage/sqlite-session-store";
|
|
|
14
14
|
import type { ToolExecutors } from "../tools";
|
|
15
15
|
import { DefaultSessionManager } from "./default-session-manager";
|
|
16
16
|
import { RpcCoreSessionService } from "./rpc-session-service";
|
|
17
|
+
import { tryAcquireRpcSpawnLease } from "./rpc-spawn-lease";
|
|
17
18
|
import type { SessionManager } from "./session-manager";
|
|
18
19
|
import { CoreSessionService } from "./session-service";
|
|
19
20
|
|
|
@@ -44,13 +45,19 @@ export interface CreateSessionHostOptions {
|
|
|
44
45
|
export type SessionHost = SessionManager;
|
|
45
46
|
|
|
46
47
|
function startRpcServerInBackground(address: string): void {
|
|
48
|
+
const lease = tryAcquireRpcSpawnLease(address);
|
|
49
|
+
if (!lease) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
47
52
|
const launcher = process.execPath;
|
|
48
53
|
const entryArg = process.argv[1]?.trim();
|
|
49
54
|
if (!entryArg) {
|
|
55
|
+
lease.release();
|
|
50
56
|
return;
|
|
51
57
|
}
|
|
52
58
|
const entry = resolve(process.cwd(), entryArg);
|
|
53
59
|
if (!existsSync(entry)) {
|
|
60
|
+
lease.release();
|
|
54
61
|
return;
|
|
55
62
|
}
|
|
56
63
|
const conditionsArg = process.execArgv.find((arg) =>
|
|
@@ -75,6 +82,7 @@ function startRpcServerInBackground(address: string): void {
|
|
|
75
82
|
cwd: process.cwd(),
|
|
76
83
|
});
|
|
77
84
|
child.unref();
|
|
85
|
+
setTimeout(() => lease.release(), 10_000).unref();
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
async function tryConnectRpcBackend(
|