@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.
@@ -0,0 +1,7 @@
1
+ export interface RpcSpawnLease {
2
+ path: string;
3
+ release: () => void;
4
+ }
5
+ export declare function tryAcquireRpcSpawnLease(address: string, options?: {
6
+ ttlMs?: number;
7
+ }): RpcSpawnLease | undefined;
@@ -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;
@@ -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
- file_text: z.ZodOptional<z.ZodNullable<z.ZodString>>;
71
- old_str: z.ZodOptional<z.ZodNullable<z.ZodString>>;
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
  /**
@@ -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.6",
3
+ "version": "0.0.7",
4
4
  "main": "./dist/index.node.js",
5
5
  "dependencies": {
6
- "@clinebot/agents": "0.0.6",
7
- "@clinebot/llms": "0.0.6",
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
- 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 {
@@ -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(