@blogic-cz/agent-tools 0.14.15 → 0.14.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.14.15",
3
+ "version": "0.14.20",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -0,0 +1,237 @@
1
+ import { Glob } from "bun";
2
+ import { Effect } from "effect";
3
+
4
+ import type { MessageSummary } from "./types";
5
+
6
+ import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
7
+
8
+ export type CodexContentBlock =
9
+ | { type: "input_text"; text: string }
10
+ | { type: "output_text"; text: string }
11
+ | { type: string; text?: string };
12
+
13
+ export type CodexRecord =
14
+ | {
15
+ type: "session_meta";
16
+ timestamp: string;
17
+ payload: { id: string; cwd?: string; timestamp?: string };
18
+ }
19
+ | {
20
+ type: "event_msg";
21
+ timestamp: string;
22
+ payload: { type: "thread_name_updated"; thread_name: string };
23
+ }
24
+ | {
25
+ type: "response_item";
26
+ timestamp: string;
27
+ payload: {
28
+ type: "message";
29
+ role: "user" | "assistant" | "developer" | string;
30
+ content: ReadonlyArray<CodexContentBlock>;
31
+ };
32
+ };
33
+
34
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
35
+ typeof value === "object" && value !== null;
36
+
37
+ export const parseCodexLine = (line: string): CodexRecord | null => {
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(line);
41
+ } catch {
42
+ return null;
43
+ }
44
+
45
+ if (
46
+ !isRecord(parsed) ||
47
+ typeof parsed.type !== "string" ||
48
+ typeof parsed.timestamp !== "string"
49
+ ) {
50
+ return null;
51
+ }
52
+
53
+ const payload = parsed.payload;
54
+ if (!isRecord(payload)) {
55
+ return null;
56
+ }
57
+
58
+ if (parsed.type === "session_meta" && typeof payload.id === "string") {
59
+ return {
60
+ type: "session_meta",
61
+ timestamp: parsed.timestamp,
62
+ payload: {
63
+ id: payload.id,
64
+ cwd: typeof payload.cwd === "string" ? payload.cwd : undefined,
65
+ timestamp: typeof payload.timestamp === "string" ? payload.timestamp : undefined,
66
+ },
67
+ };
68
+ }
69
+
70
+ if (
71
+ parsed.type === "event_msg" &&
72
+ payload.type === "thread_name_updated" &&
73
+ typeof payload.thread_name === "string"
74
+ ) {
75
+ return {
76
+ type: "event_msg",
77
+ timestamp: parsed.timestamp,
78
+ payload: { type: "thread_name_updated", thread_name: payload.thread_name },
79
+ };
80
+ }
81
+
82
+ if (
83
+ parsed.type === "response_item" &&
84
+ payload.type === "message" &&
85
+ typeof payload.role === "string" &&
86
+ Array.isArray(payload.content)
87
+ ) {
88
+ return {
89
+ type: "response_item",
90
+ timestamp: parsed.timestamp,
91
+ payload: {
92
+ type: "message",
93
+ role: payload.role,
94
+ content: (payload.content as unknown[]).filter(
95
+ (item): item is CodexContentBlock =>
96
+ isRecord(item) && typeof (item as Record<string, unknown>).type === "string",
97
+ ),
98
+ },
99
+ };
100
+ }
101
+
102
+ return null;
103
+ };
104
+
105
+ export const extractCodexText = (content: ReadonlyArray<CodexContentBlock>): string =>
106
+ content
107
+ .filter(
108
+ (block): block is CodexContentBlock & { text: string } =>
109
+ (block.type === "input_text" || block.type === "output_text") &&
110
+ typeof block.text === "string",
111
+ )
112
+ .map((block) => block.text)
113
+ .join("\n");
114
+
115
+ export const extractCodexTitle = (records: ReadonlyArray<CodexRecord>): string => {
116
+ const named = records.find((record) => record.type === "event_msg");
117
+ if (named !== undefined && named.type === "event_msg") {
118
+ return named.payload.thread_name;
119
+ }
120
+
121
+ const firstUser = records.find(
122
+ (record) => record.type === "response_item" && record.payload.role === "user",
123
+ );
124
+ if (firstUser !== undefined && firstUser.type === "response_item") {
125
+ return extractCodexText(firstUser.payload.content).slice(0, 100);
126
+ }
127
+
128
+ return "Untitled session";
129
+ };
130
+
131
+ const getSessionIdFromFile = (filePath: string): string => {
132
+ const fileName = filePath.split("/").pop() ?? "";
133
+ const match =
134
+ /rollout-.*?-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/u.exec(
135
+ fileName,
136
+ );
137
+ return match?.[1] ?? fileName.replace(/\.jsonl$/u, "");
138
+ };
139
+
140
+ export const getCodexSessionId = getSessionIdFromFile;
141
+
142
+ const walkSessionFiles = (basePath: string): Promise<string[]> => {
143
+ const glob = new Glob("*/*/*/rollout-*.jsonl");
144
+ return Array.fromAsync(glob.scan({ cwd: basePath, absolute: true }));
145
+ };
146
+
147
+ const readSessionMeta = async (
148
+ sessionFile: string,
149
+ ): Promise<{ id: string; cwd?: string } | null> => {
150
+ try {
151
+ const text = await Bun.file(sessionFile).text();
152
+ const firstLine = text.split("\n")[0] ?? "";
153
+ const record = parseCodexLine(firstLine);
154
+ if (record !== null && record.type === "session_meta") {
155
+ return { id: record.payload.id, cwd: record.payload.cwd };
156
+ }
157
+ return null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ };
162
+
163
+ export const getCodexSessions = (
164
+ basePath: string,
165
+ projectDir: string | null,
166
+ ): Effect.Effect<string[], SessionError> =>
167
+ Effect.tryPromise({
168
+ try: async () => {
169
+ const allFiles = await walkSessionFiles(basePath);
170
+ if (projectDir === null) {
171
+ return allFiles;
172
+ }
173
+
174
+ const metas = await Promise.all(allFiles.map((file) => readSessionMeta(file)));
175
+ return allFiles.filter((_, i) => metas[i] !== null && metas[i]?.cwd === projectDir);
176
+ },
177
+ catch: (error) =>
178
+ new SessionStorageNotFoundError({
179
+ message: error instanceof Error ? error.message : "Codex storage directory not found",
180
+ path: basePath,
181
+ }),
182
+ });
183
+
184
+ export const readCodexMessages = (
185
+ sessionFiles: string[],
186
+ ): Effect.Effect<MessageSummary[], SessionError> =>
187
+ Effect.tryPromise({
188
+ try: async () => {
189
+ const summaries: MessageSummary[] = [];
190
+
191
+ for (const sessionFile of sessionFiles) {
192
+ let fileContent: string;
193
+ try {
194
+ // eslint-disable-next-line eslint/no-await-in-loop -- sequential file read keeps memory bounded
195
+ fileContent = await Bun.file(sessionFile).text();
196
+ } catch {
197
+ continue;
198
+ }
199
+
200
+ const records = fileContent
201
+ .split(/\r?\n/u)
202
+ .map((line) => line.trim())
203
+ .filter((line) => line.length > 0)
204
+ .map(parseCodexLine)
205
+ .filter((record): record is CodexRecord => record !== null);
206
+
207
+ const title = extractCodexTitle(records);
208
+ const sessionID = getSessionIdFromFile(sessionFile);
209
+
210
+ for (const record of records) {
211
+ if (record.type !== "response_item") continue;
212
+ if (record.payload.role !== "user" && record.payload.role !== "assistant") continue;
213
+
214
+ const body = extractCodexText(record.payload.content);
215
+ if (body.length === 0) continue;
216
+
217
+ const createdTimestamp = new Date(record.timestamp).getTime();
218
+ summaries.push({
219
+ sessionID,
220
+ id: `${sessionID}:${record.timestamp}`,
221
+ title,
222
+ body,
223
+ created: Number.isFinite(createdTimestamp) ? createdTimestamp : 0,
224
+ role: record.payload.role,
225
+ source: "codex",
226
+ });
227
+ }
228
+ }
229
+
230
+ return summaries.toSorted((left, right) => right.created - left.created);
231
+ },
232
+ catch: (error) =>
233
+ new SessionReadError({
234
+ message: error instanceof Error ? error.message : "Failed to read Codex sessions",
235
+ source: "codex",
236
+ }),
237
+ });
@@ -44,6 +44,7 @@ export class ResolvedPaths extends Context.Service<
44
44
  readonly messagesPath: string;
45
45
  readonly sessionsPath: string;
46
46
  readonly claudeCodePath: string | null;
47
+ readonly codexPath: string | null;
47
48
  }
48
49
  >()("@agent-tools/ResolvedPaths") {}
49
50
 
@@ -54,6 +55,8 @@ export const ResolvedPathsLayer = Layer.effect(
54
55
  const sessionsPath = yield* resolveSessionsPath;
55
56
  const claudeCodeBasePath = join(homedir(), ".claude/projects");
56
57
  const claudeCodePath = existsSync(claudeCodeBasePath) ? claudeCodeBasePath : null;
57
- return { messagesPath, sessionsPath, claudeCodePath };
58
+ const codexBasePath = join(homedir(), ".codex/sessions");
59
+ const codexPath = existsSync(codexBasePath) ? codexBasePath : null;
60
+ return { messagesPath, sessionsPath, claudeCodePath, codexPath };
58
61
  }),
59
62
  );
@@ -22,7 +22,7 @@ import { formatDate, SessionService, SessionServiceLayer, truncate } from "./ser
22
22
  const AppLayer = SessionServiceLayer.pipe(Layer.provideMerge(ResolvedPathsLayer));
23
23
 
24
24
  const sourceOption = Flag.string("source").pipe(
25
- Flag.withDescription("Filter by source: all, opencode, claude-code"),
25
+ Flag.withDescription("Filter by source: all, opencode, claude-code, codex"),
26
26
  Flag.withDefault("all"),
27
27
  );
28
28
 
@@ -1,9 +1,10 @@
1
1
  import { Context, Effect, Layer } from "effect";
2
2
  import { readdir } from "node:fs/promises";
3
3
 
4
- import type { MessageSummary, SessionInfo } from "./types";
4
+ import type { MessageSummary, SessionInfo, SessionSource } from "./types";
5
5
 
6
6
  import { getClaudeCodeSessions, readClaudeCodeMessages } from "./claude-code";
7
+ import { getCodexSessions, getCodexSessionId, readCodexMessages } from "./codex";
7
8
  import { ResolvedPaths } from "./config";
8
9
  import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
9
10
 
@@ -38,7 +39,11 @@ export const truncate = (value: string, maxLen: number): string => {
38
39
 
39
40
  type FileEntry = { filePath: string; content: string };
40
41
 
41
- type SourceFilter = "both" | "opencode" | "claude-code";
42
+ type SourceFilter = ReadonlySet<SessionSource>;
43
+
44
+ const ALL_SOURCES: SourceFilter = new Set<SessionSource>(["opencode", "claude-code", "codex"]);
45
+ const UUID_SOURCES: SourceFilter = new Set<SessionSource>(["claude-code", "codex"]);
46
+ const OPENCODE_ONLY: SourceFilter = new Set<SessionSource>(["opencode"]);
42
47
 
43
48
  const UUID_SESSION_ID_REGEX =
44
49
  /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/u;
@@ -50,23 +55,23 @@ const getSessionIdFromClaudeFile = (filePath: string): string => {
50
55
 
51
56
  const detectSourceFilter = (filterSessions: Set<string> | null): SourceFilter => {
52
57
  if (filterSessions === null || filterSessions.size !== 1) {
53
- return "both";
58
+ return ALL_SOURCES;
54
59
  }
55
60
 
56
61
  const sessionId = filterSessions.values().next().value;
57
62
  if (typeof sessionId !== "string") {
58
- return "both";
63
+ return ALL_SOURCES;
59
64
  }
60
65
 
61
66
  if (sessionId.startsWith("ses_")) {
62
- return "opencode";
67
+ return OPENCODE_ONLY;
63
68
  }
64
69
 
65
70
  if (UUID_SESSION_ID_REGEX.test(sessionId)) {
66
- return "claude-code";
71
+ return UUID_SOURCES;
67
72
  }
68
73
 
69
- return "both";
74
+ return ALL_SOURCES;
70
75
  };
71
76
 
72
77
  /**
@@ -196,10 +201,26 @@ export class SessionService extends Context.Service<
196
201
  ),
197
202
  );
198
203
 
204
+ const codexSessions =
205
+ paths.codexPath === null
206
+ ? new Set<string>()
207
+ : yield* getCodexSessions(paths.codexPath, projectDir).pipe(
208
+ Effect.map(
209
+ (files) =>
210
+ new Set<string>(files.map((filePath) => getCodexSessionId(filePath))),
211
+ ),
212
+ Effect.catchTag("SessionStorageNotFoundError", () =>
213
+ Effect.succeed(new Set<string>()),
214
+ ),
215
+ );
216
+
199
217
  const matchingSessions = new Set<string>(opencodeSessions);
200
218
  for (const sessionId of claudeSessions) {
201
219
  matchingSessions.add(sessionId);
202
220
  }
221
+ for (const sessionId of codexSessions) {
222
+ matchingSessions.add(sessionId);
223
+ }
203
224
 
204
225
  return matchingSessions;
205
226
  }),
@@ -209,65 +230,64 @@ export class SessionService extends Context.Service<
209
230
  ) {
210
231
  const sourceFilter = detectSourceFilter(filterSessions);
211
232
 
212
- const opencodeSummaries =
213
- sourceFilter === "claude-code"
214
- ? []
215
- : yield* Effect.gen(function* () {
216
- const sessionDirs = yield* Effect.tryPromise({
217
- try: async () => {
218
- const dirs = await readdir(paths.messagesPath);
219
- return dirs
220
- .filter((name) => name.startsWith("ses_"))
221
- .filter((name) => filterSessions === null || filterSessions.has(name));
222
- },
223
- catch: () =>
224
- new SessionStorageNotFoundError({
225
- message: "Message storage directory not found",
226
- path: paths.messagesPath,
227
- }),
228
- });
229
-
230
- const summaries: MessageSummary[] = [];
231
-
232
- for (const sessionId of sessionDirs) {
233
- const sessionPath = `${paths.messagesPath}/${sessionId}`;
234
- const files = yield* readJsonFilesFlat(sessionPath);
235
-
236
- for (const { filePath, content } of files) {
237
- const parsed = parseJson<{
238
- id?: string;
239
- role?: string;
240
- sessionID?: string;
241
- summary?: {
242
- body?: string;
243
- title?: string;
244
- };
245
- time?: {
246
- created?: number;
247
- };
248
- }>(content);
249
-
250
- if (parsed === null || parsed.summary?.title === undefined) {
251
- continue;
252
- }
253
-
254
- summaries.push({
255
- sessionID: parsed.sessionID ?? sessionId,
256
- id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
257
- title: parsed.summary.title,
258
- body: parsed.summary.body ?? "",
259
- created: parsed.time?.created ?? 0,
260
- role: parsed.role ?? "unknown",
261
- source: "opencode",
262
- });
233
+ const opencodeSummaries = !sourceFilter.has("opencode")
234
+ ? []
235
+ : yield* Effect.gen(function* () {
236
+ const sessionDirs = yield* Effect.tryPromise({
237
+ try: async () => {
238
+ const dirs = await readdir(paths.messagesPath);
239
+ return dirs
240
+ .filter((name) => name.startsWith("ses_"))
241
+ .filter((name) => filterSessions === null || filterSessions.has(name));
242
+ },
243
+ catch: () =>
244
+ new SessionStorageNotFoundError({
245
+ message: "Message storage directory not found",
246
+ path: paths.messagesPath,
247
+ }),
248
+ });
249
+
250
+ const summaries: MessageSummary[] = [];
251
+
252
+ for (const sessionId of sessionDirs) {
253
+ const sessionPath = `${paths.messagesPath}/${sessionId}`;
254
+ const files = yield* readJsonFilesFlat(sessionPath);
255
+
256
+ for (const { filePath, content } of files) {
257
+ const parsed = parseJson<{
258
+ id?: string;
259
+ role?: string;
260
+ sessionID?: string;
261
+ summary?: {
262
+ body?: string;
263
+ title?: string;
264
+ };
265
+ time?: {
266
+ created?: number;
267
+ };
268
+ }>(content);
269
+
270
+ if (parsed === null || parsed.summary?.title === undefined) {
271
+ continue;
263
272
  }
273
+
274
+ summaries.push({
275
+ sessionID: parsed.sessionID ?? sessionId,
276
+ id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
277
+ title: parsed.summary.title,
278
+ body: parsed.summary.body ?? "",
279
+ created: parsed.time?.created ?? 0,
280
+ role: parsed.role ?? "unknown",
281
+ source: "opencode",
282
+ });
264
283
  }
284
+ }
265
285
 
266
- return summaries;
267
- }).pipe(Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])));
286
+ return summaries;
287
+ }).pipe(Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])));
268
288
 
269
289
  const claudeSummaries =
270
- sourceFilter === "opencode" || paths.claudeCodePath === null
290
+ !sourceFilter.has("claude-code") || paths.claudeCodePath === null
271
291
  ? []
272
292
  : yield* getClaudeCodeSessions(paths.claudeCodePath, null).pipe(
273
293
  Effect.map((sessionFiles) =>
@@ -281,7 +301,25 @@ export class SessionService extends Context.Service<
281
301
  Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])),
282
302
  );
283
303
 
284
- const summaries = [...opencodeSummaries, ...claudeSummaries];
304
+ const codexSummaries =
305
+ !sourceFilter.has("codex") || paths.codexPath === null
306
+ ? []
307
+ : yield* getCodexSessions(paths.codexPath, null).pipe(
308
+ Effect.map((sessionFiles) =>
309
+ filterSessions === null
310
+ ? sessionFiles
311
+ : sessionFiles.filter((sessionFile) =>
312
+ filterSessions.has(getCodexSessionId(sessionFile)),
313
+ ),
314
+ ),
315
+ Effect.flatMap(readCodexMessages),
316
+ Effect.catchTags({
317
+ SessionStorageNotFoundError: () => Effect.succeed([]),
318
+ SessionReadError: () => Effect.succeed([]),
319
+ }),
320
+ );
321
+
322
+ const summaries = [...opencodeSummaries, ...claudeSummaries, ...codexSummaries];
285
323
 
286
324
  return (
287
325
  summaries as MessageSummary[] & {
@@ -1,3 +1,5 @@
1
+ import { Schema } from "effect";
2
+
1
3
  import type { OutputFormat } from "#shared";
2
4
 
3
5
  export type { OutputFormat };
@@ -8,7 +10,8 @@ export type SessionInfo = {
8
10
  projectID: string;
9
11
  };
10
12
 
11
- export type SessionSource = "opencode" | "claude-code";
13
+ export const SessionSourceLiterals = Schema.Literals(["opencode", "claude-code", "codex"]);
14
+ export type SessionSource = Schema.Schema.Type<typeof SessionSourceLiterals>;
12
15
 
13
16
  export type MessageSummary = {
14
17
  sessionID: string;
@@ -0,0 +1,15 @@
1
+ const trimSlashes = (segment: string) => segment.replace(/^\/+|\/+$/g, "");
2
+
3
+ export const joinPath = (first: string, ...rest: readonly string[]) => {
4
+ const isAbsolute = first.startsWith("/");
5
+ const joined = [first, ...rest]
6
+ .map(trimSlashes)
7
+ .filter((segment) => segment !== "")
8
+ .join("/");
9
+
10
+ if (!isAbsolute) {
11
+ return joined;
12
+ }
13
+
14
+ return joined === "" ? "/" : `/${joined}`;
15
+ };
@@ -1,15 +1,364 @@
1
+ // Synchronous node:fs calls keep the cross-process lock/lease critical section atomic;
2
+ // Bun does not provide equivalent synchronous directory primitives for this use case.
3
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+
1
5
  import { Clock, Duration, Effect, Result } from "effect";
2
6
  import { ChildProcess } from "effect/unstable/process";
3
7
 
4
8
  import type { AgentToolsConfig, ProfilePrerequisites } from "#config/types";
5
- import type { PrerequisiteCommandRunner, ResolvedVpnDriver } from "#shared/prerequisites/types";
6
-
9
+ import type {
10
+ PrerequisiteCommandRunner,
11
+ ResolvedVpnDriver,
12
+ VpnCleanupPolicy,
13
+ VpnLease,
14
+ VpnLeaseHandle,
15
+ VpnLockOwner,
16
+ VpnStartState,
17
+ } from "#shared/prerequisites/types";
18
+
19
+ import { joinPath } from "#shared/path";
7
20
  import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
8
21
  import { PrerequisiteRunError } from "#shared/prerequisites/errors";
9
22
  import { missingVpnToolHint, resolveVpnDriverConfig } from "#shared/prerequisites/vpn";
10
23
 
11
24
  const readEnv = (name: string) => Bun.env[name];
12
25
 
26
+ const DEFAULT_LEASE_TTL_MS = 10 * 60 * 1000;
27
+ const LOCK_STALE_MS = 30_000;
28
+ const LOCK_RETRY_MS = 25;
29
+ const LOCK_TIMEOUT_BUFFER_MS = 5_000;
30
+
31
+ const getRuntimeRoot = () =>
32
+ readEnv("AGENT_TOOLS_RUNTIME_DIR") ??
33
+ joinPath(readEnv("TMPDIR") ?? readEnv("TEMP") ?? readEnv("TMP") ?? "/tmp", "agent-tools");
34
+
35
+ const getDriverIdentity = (driver: ResolvedVpnDriver) => {
36
+ if (driver.type === "macos-scutil") {
37
+ return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
38
+ }
39
+
40
+ if (driver.type === "linux-nmcli") {
41
+ return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
42
+ }
43
+
44
+ return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
45
+ };
46
+
47
+ const getDriverLeaseKey = (driver: ResolvedVpnDriver) =>
48
+ Bun.hash(JSON.stringify(getDriverIdentity(driver))).toString(16);
49
+
50
+ const makeLeaseHandle = (
51
+ driver: ResolvedVpnDriver,
52
+ ttlMs: number,
53
+ lockTimeoutMs: number,
54
+ ): VpnLeaseHandle => {
55
+ const key = getDriverLeaseKey(driver);
56
+ const directory = joinPath(getRuntimeRoot(), "vpn-prerequisites", key);
57
+ return {
58
+ directory,
59
+ leasePath: joinPath(directory, `lease-${process.pid}.json`),
60
+ statePath: joinPath(directory, "started.json"),
61
+ lockPath: joinPath(directory, "lock"),
62
+ ttlMs,
63
+ lockTimeoutMs,
64
+ };
65
+ };
66
+
67
+ const getErrorMessage = (error: unknown) =>
68
+ error instanceof Error ? error.message : String(error);
69
+
70
+ const hasErrorCode = (error: unknown, code: string) =>
71
+ typeof error === "object" && error !== null && "code" in error && error.code === code;
72
+
73
+ const fsError = (message: string, error: unknown) =>
74
+ new PrerequisiteRunError({
75
+ message: `${message}: ${getErrorMessage(error)}`,
76
+ hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
77
+ });
78
+
79
+ const syncFs = <A>(message: string, operation: () => A) =>
80
+ Effect.try({
81
+ try: operation,
82
+ catch: (error) => fsError(message, error),
83
+ });
84
+
85
+ const readJsonFile = (path: string): unknown | undefined => {
86
+ try {
87
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
88
+ return parsed;
89
+ } catch (error) {
90
+ void error;
91
+ return undefined;
92
+ }
93
+ };
94
+
95
+ type JsonObject = { readonly [key: string]: unknown };
96
+
97
+ const isJsonObject = (value: unknown): value is JsonObject =>
98
+ typeof value === "object" && value !== null;
99
+
100
+ const isFiniteNumber = (value: unknown): value is number =>
101
+ typeof value === "number" && Number.isFinite(value);
102
+
103
+ const isVpnLease = (value: unknown): value is VpnLease =>
104
+ isJsonObject(value) &&
105
+ isFiniteNumber(value.pid) &&
106
+ isFiniteNumber(value.createdAt) &&
107
+ isFiniteNumber(value.updatedAt);
108
+
109
+ const isVpnStartState = (value: unknown): value is VpnStartState =>
110
+ isJsonObject(value) && isFiniteNumber(value.pid) && isFiniteNumber(value.startedAt);
111
+
112
+ const isVpnLockOwner = (value: unknown): value is VpnLockOwner =>
113
+ isJsonObject(value) && isFiniteNumber(value.pid) && isFiniteNumber(value.createdAt);
114
+
115
+ const readVpnLease = (path: string) => {
116
+ const parsed = readJsonFile(path);
117
+ return isVpnLease(parsed) ? parsed : undefined;
118
+ };
119
+
120
+ const readVpnStartState = (path: string) => {
121
+ const parsed = readJsonFile(path);
122
+ return isVpnStartState(parsed) ? parsed : undefined;
123
+ };
124
+
125
+ const readVpnLockOwner = (path: string) => {
126
+ const parsed = readJsonFile(path);
127
+ return isVpnLockOwner(parsed) ? parsed : undefined;
128
+ };
129
+
130
+ const isPidLive = (pid: number) => {
131
+ if (!Number.isInteger(pid) || pid <= 0) {
132
+ return false;
133
+ }
134
+
135
+ try {
136
+ process.kill(pid, 0);
137
+ return true;
138
+ } catch (error) {
139
+ return error instanceof Error && "code" in error && error.code === "EPERM";
140
+ }
141
+ };
142
+
143
+ const isLeaseLive = (lease: VpnLease | undefined, now: number, ttlMs: number) => {
144
+ if (!lease) {
145
+ return false;
146
+ }
147
+
148
+ if (isPidLive(lease.pid)) {
149
+ return true;
150
+ }
151
+
152
+ return now - lease.updatedAt <= ttlMs;
153
+ };
154
+
155
+ const pruneStaleLeases = (handle: VpnLeaseHandle, now: number) =>
156
+ syncFs("Failed to prune VPN prerequisite lease files", () => {
157
+ for (const entry of readdirSync(handle.directory, { withFileTypes: true })) {
158
+ if (!entry.isFile() || !entry.name.startsWith("lease-") || !entry.name.endsWith(".json")) {
159
+ continue;
160
+ }
161
+
162
+ const leasePath = joinPath(handle.directory, entry.name);
163
+ const lease = readVpnLease(leasePath);
164
+ if (!isLeaseLive(lease, now, handle.ttlMs)) {
165
+ rmSync(leasePath, { force: true });
166
+ }
167
+ }
168
+ });
169
+
170
+ const hasOtherLiveLeases = (handle: VpnLeaseHandle, now: number) =>
171
+ Effect.gen(function* () {
172
+ yield* pruneStaleLeases(handle, now);
173
+
174
+ return yield* syncFs("Failed to inspect VPN prerequisite lease files", () => {
175
+ for (const entry of readdirSync(handle.directory, { withFileTypes: true })) {
176
+ if (!entry.isFile() || !entry.name.startsWith("lease-") || !entry.name.endsWith(".json")) {
177
+ continue;
178
+ }
179
+
180
+ const leasePath = joinPath(handle.directory, entry.name);
181
+ if (leasePath === handle.leasePath) {
182
+ continue;
183
+ }
184
+
185
+ const lease = readVpnLease(leasePath);
186
+ if (isLeaseLive(lease, now, handle.ttlMs)) {
187
+ return true;
188
+ }
189
+ }
190
+
191
+ return false;
192
+ });
193
+ });
194
+
195
+ const writeLease = (handle: VpnLeaseHandle, now: number) =>
196
+ syncFs("Failed to write VPN prerequisite lease", () => {
197
+ mkdirSync(handle.directory, { recursive: true });
198
+ const existingLease = readVpnLease(handle.leasePath);
199
+ const lease: VpnLease = {
200
+ pid: process.pid,
201
+ createdAt: existingLease?.createdAt ?? now,
202
+ updatedAt: now,
203
+ };
204
+ writeFileSync(handle.leasePath, JSON.stringify(lease));
205
+ });
206
+
207
+ const writeStartState = (handle: VpnLeaseHandle, now: number) =>
208
+ syncFs("Failed to write VPN prerequisite start state", () => {
209
+ const state: VpnStartState = { pid: process.pid, startedAt: now };
210
+ writeFileSync(handle.statePath, JSON.stringify(state));
211
+ });
212
+
213
+ const readStartState = (handle: VpnLeaseHandle) =>
214
+ syncFs("Failed to read VPN prerequisite start state", () => readVpnStartState(handle.statePath));
215
+
216
+ const removeOwnLease = (handle: VpnLeaseHandle) =>
217
+ syncFs("Failed to remove VPN prerequisite lease", () => {
218
+ rmSync(handle.leasePath, { force: true });
219
+ });
220
+
221
+ const removeStartState = (handle: VpnLeaseHandle) =>
222
+ syncFs("Failed to remove VPN prerequisite start state", () => {
223
+ rmSync(handle.statePath, { force: true });
224
+ });
225
+
226
+ const getLockDirectoryAgeMs = (handle: VpnLeaseHandle, now: number) =>
227
+ syncFs("Failed to inspect VPN prerequisite lease lock", () => {
228
+ let stats: ReturnType<typeof statSync>;
229
+ try {
230
+ stats = statSync(handle.lockPath);
231
+ } catch (error) {
232
+ if (hasErrorCode(error, "ENOENT")) {
233
+ return undefined;
234
+ }
235
+
236
+ throw error;
237
+ }
238
+
239
+ return now - stats.mtimeMs;
240
+ });
241
+
242
+ const isLockOwnerStale = (
243
+ owner: VpnLockOwner | undefined,
244
+ lockAgeMs: number,
245
+ timeoutMs: number,
246
+ ) => {
247
+ const staleThresholdMs = Math.max(LOCK_STALE_MS, timeoutMs);
248
+ if (!owner) {
249
+ return lockAgeMs > staleThresholdMs;
250
+ }
251
+
252
+ return !isPidLive(owner.pid) && lockAgeMs > staleThresholdMs;
253
+ };
254
+
255
+ const acquireFileLock = (handle: VpnLeaseHandle) =>
256
+ Effect.gen(function* () {
257
+ yield* syncFs("Failed to create VPN prerequisite lease directory", () => {
258
+ mkdirSync(handle.directory, { recursive: true });
259
+ });
260
+ const start = yield* Clock.currentTimeMillis;
261
+
262
+ while (true) {
263
+ const now = yield* Clock.currentTimeMillis;
264
+ let lockError: unknown;
265
+ try {
266
+ mkdirSync(handle.lockPath);
267
+ writeFileSync(
268
+ joinPath(handle.lockPath, "owner.json"),
269
+ `{"pid":${process.pid},"createdAt":${Number(now)}}`,
270
+ );
271
+ return;
272
+ } catch (error) {
273
+ lockError = error;
274
+ }
275
+
276
+ if (!hasErrorCode(lockError, "EEXIST")) {
277
+ return yield* fsError("Failed to acquire VPN prerequisite lease lock", lockError);
278
+ }
279
+
280
+ const lockOwner = readVpnLockOwner(joinPath(handle.lockPath, "owner.json"));
281
+ const lockAgeMs = yield* getLockDirectoryAgeMs(handle, Number(now));
282
+ if (lockAgeMs === undefined) {
283
+ continue;
284
+ }
285
+
286
+ if (isLockOwnerStale(lockOwner, lockAgeMs, handle.lockTimeoutMs)) {
287
+ yield* syncFs("Failed to remove stale VPN prerequisite lease lock", () => {
288
+ rmSync(handle.lockPath, { recursive: true, force: true });
289
+ });
290
+ continue;
291
+ }
292
+
293
+ if (Number(now) - Number(start) > handle.lockTimeoutMs) {
294
+ return yield* new PrerequisiteRunError({
295
+ message: "Timed out while waiting for VPN prerequisite lease lock.",
296
+ hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
297
+ });
298
+ }
299
+
300
+ yield* Effect.sleep(Duration.millis(LOCK_RETRY_MS));
301
+ }
302
+ });
303
+
304
+ const releaseFileLock = (handle: VpnLeaseHandle) =>
305
+ syncFs("Failed to release VPN prerequisite lease lock", () => {
306
+ rmSync(handle.lockPath, { recursive: true, force: true });
307
+ }).pipe(Effect.ignore);
308
+
309
+ const withVpnLeaseLock = <A, E>(
310
+ handle: VpnLeaseHandle,
311
+ effect: Effect.Effect<A, E, never>,
312
+ ): Effect.Effect<A, E | PrerequisiteRunError, never> =>
313
+ Effect.acquireRelease(acquireFileLock(handle), () => releaseFileLock(handle)).pipe(
314
+ Effect.flatMap(() => effect),
315
+ Effect.scoped,
316
+ );
317
+
318
+ type HeldVpnLease = {
319
+ readonly handle: VpnLeaseHandle;
320
+ readonly driver: ResolvedVpnDriver;
321
+ readonly cleanup: VpnCleanupPolicy;
322
+ readonly cooldownMs: number;
323
+ };
324
+
325
+ const cleanupHeldLeases = <CommandError>(
326
+ heldLeases: readonly HeldVpnLease[],
327
+ runCommand: PrerequisiteCommandRunner<CommandError>,
328
+ ) =>
329
+ Effect.gen(function* () {
330
+ for (const held of heldLeases.toReversed()) {
331
+ if (held.cooldownMs > 0) {
332
+ yield* Effect.sleep(Duration.millis(held.cooldownMs));
333
+ }
334
+
335
+ yield* withVpnLeaseLock(
336
+ held.handle,
337
+ Effect.gen(function* () {
338
+ const now = yield* Clock.currentTimeMillis;
339
+ yield* removeOwnLease(held.handle);
340
+ if (held.cleanup === "leave-running") {
341
+ // Treat the agent-started VPN as intentionally adopted so later default runs do not stop it.
342
+ yield* removeStartState(held.handle);
343
+ return;
344
+ }
345
+
346
+ const state = yield* readStartState(held.handle);
347
+ const hasOtherLeases = yield* hasOtherLiveLeases(held.handle, Number(now));
348
+ const shouldStop = state !== undefined && !hasOtherLeases;
349
+
350
+ if (!shouldStop) {
351
+ return;
352
+ }
353
+
354
+ const stopCommand = makeVpnCommand(held.driver, "stop");
355
+ yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
356
+ yield* removeStartState(held.handle);
357
+ }),
358
+ ).pipe(Effect.ignore);
359
+ }
360
+ });
361
+
13
362
  const makeVpnCommand = (driver: ResolvedVpnDriver, action: "status" | "start" | "stop") => {
14
363
  if (driver.type === "macos-scutil") {
15
364
  const secret = driver.secretEnvVar ? readEnv(driver.secretEnvVar) : undefined;
@@ -145,87 +494,131 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
145
494
  }
146
495
 
147
496
  const prerequisiteResult = yield* Effect.gen(function* () {
148
- const startedDrivers: Array<{ driver: ResolvedVpnDriver; cooldownMs: number }> = [];
149
-
150
- for (const prerequisite of vpnPrerequisites) {
151
- const vpnConfig = config.vpns?.[prerequisite.key];
152
- if (!vpnConfig) {
153
- return yield* new PrerequisiteRunError({
154
- message: `VPN prerequisite "${prerequisite.key}" is not defined.`,
155
- hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
156
- });
157
- }
158
-
159
- const driverResolution = resolveVpnDriverConfig(vpnConfig);
160
- if (!driverResolution.success) {
161
- return yield* new PrerequisiteRunError({
162
- message: driverResolution.error,
163
- hint: driverResolution.hint,
164
- });
165
- }
166
-
167
- const driver = driverResolution.driver;
168
- const wasConnected = yield* isVpnConnected(driver, runCommand);
169
- if (wasConnected) {
170
- continue;
171
- }
497
+ const heldLeases: HeldVpnLease[] = [];
498
+ const acquirePrerequisites = Effect.gen(function* () {
499
+ for (const prerequisite of vpnPrerequisites) {
500
+ const vpnConfig = config.vpns?.[prerequisite.key];
501
+ if (!vpnConfig) {
502
+ return yield* new PrerequisiteRunError({
503
+ message: `VPN prerequisite "${prerequisite.key}" is not defined.`,
504
+ hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
505
+ });
506
+ }
172
507
 
173
- if (
174
- driver.type === "macos-scutil" &&
175
- driver.secretEnvVar &&
176
- !readEnv(driver.secretEnvVar)
177
- ) {
178
- return yield* new PrerequisiteRunError({
179
- message: `VPN secret environment variable "${driver.secretEnvVar}" is not set.`,
180
- hint: `Set ${driver.secretEnvVar} before running this tool or remove secretEnvVar from the VPN config.`,
181
- });
182
- }
508
+ const driverResolution = resolveVpnDriverConfig(vpnConfig);
509
+ if (!driverResolution.success) {
510
+ return yield* new PrerequisiteRunError({
511
+ message: driverResolution.error,
512
+ hint: driverResolution.hint,
513
+ });
514
+ }
183
515
 
184
- const startCommand = makeVpnCommand(driver, "start");
185
- const startResult = yield* runCommand(startCommand.command, startCommand.label).pipe(
186
- Effect.mapError(
187
- () =>
188
- new PrerequisiteRunError({
189
- message: `Failed to start VPN prerequisite "${prerequisite.key}".`,
190
- hint: missingVpnToolHint(driver),
191
- }),
192
- ),
193
- );
516
+ const driver = driverResolution.driver;
517
+ const cleanup: VpnCleanupPolicy =
518
+ prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
519
+ const connectTimeoutMs = vpnConfig.connectTimeoutMs ?? 30000;
520
+ const handle = makeLeaseHandle(
521
+ driver,
522
+ vpnConfig.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS,
523
+ connectTimeoutMs + LOCK_TIMEOUT_BUFFER_MS,
524
+ );
525
+
526
+ const acquisitionResult = yield* withVpnLeaseLock(
527
+ handle,
528
+ Effect.gen(function* () {
529
+ const result = yield* Effect.gen(function* () {
530
+ const now = yield* Clock.currentTimeMillis;
531
+ yield* writeLease(handle, Number(now));
532
+ yield* pruneStaleLeases(handle, Number(now));
533
+
534
+ const wasConnected = yield* isVpnConnected(driver, runCommand);
535
+ if (wasConnected) {
536
+ return;
537
+ }
538
+
539
+ if (
540
+ driver.type === "macos-scutil" &&
541
+ driver.secretEnvVar &&
542
+ !readEnv(driver.secretEnvVar)
543
+ ) {
544
+ return yield* new PrerequisiteRunError({
545
+ message: `VPN secret environment variable "${driver.secretEnvVar}" is not set.`,
546
+ hint: `Set ${driver.secretEnvVar} before running this tool or remove secretEnvVar from the VPN config.`,
547
+ });
548
+ }
549
+
550
+ const startCommand = makeVpnCommand(driver, "start");
551
+ const startResult = yield* runCommand(
552
+ startCommand.command,
553
+ startCommand.label,
554
+ ).pipe(
555
+ Effect.mapError(
556
+ () =>
557
+ new PrerequisiteRunError({
558
+ message: `Failed to start VPN prerequisite "${prerequisite.key}".`,
559
+ hint: missingVpnToolHint(driver),
560
+ }),
561
+ ),
562
+ );
563
+
564
+ if (startResult.exitCode !== 0) {
565
+ const stderr = startResult.stderr.trim();
566
+ return yield* new PrerequisiteRunError({
567
+ message:
568
+ stderr !== ""
569
+ ? stderr
570
+ : `Failed to start VPN prerequisite "${prerequisite.key}".`,
571
+ hint: missingVpnToolHint(driver),
572
+ });
573
+ }
574
+
575
+ const ready = yield* waitForVpn(driver, connectTimeoutMs, runCommand);
576
+ if (!ready) {
577
+ return yield* new PrerequisiteRunError({
578
+ message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
579
+ hint: missingVpnToolHint(driver),
580
+ });
581
+ }
582
+
583
+ if (cleanup === "stop-if-started") {
584
+ const connectedAt = yield* Clock.currentTimeMillis;
585
+ yield* writeStartState(handle, Number(connectedAt));
586
+ }
587
+ }).pipe(Effect.result);
588
+
589
+ if (Result.isFailure(result)) {
590
+ yield* removeOwnLease(handle).pipe(Effect.ignore);
591
+ }
592
+
593
+ return result;
594
+ }),
595
+ ).pipe(
596
+ Effect.mapError((error) =>
597
+ error instanceof PrerequisiteRunError
598
+ ? error
599
+ : new PrerequisiteRunError({
600
+ message: `Failed to coordinate VPN prerequisite "${prerequisite.key}".`,
601
+ hint: missingVpnToolHint(driver),
602
+ }),
603
+ ),
604
+ );
605
+
606
+ if (Result.isFailure(acquisitionResult)) {
607
+ return yield* Effect.fail(acquisitionResult.failure);
608
+ }
194
609
 
195
- if (startResult.exitCode !== 0) {
196
- const stderr = startResult.stderr.trim();
197
- return yield* new PrerequisiteRunError({
198
- message:
199
- stderr !== "" ? stderr : `Failed to start VPN prerequisite "${prerequisite.key}".`,
200
- hint: missingVpnToolHint(driver),
201
- });
610
+ heldLeases.push({ handle, driver, cleanup, cooldownMs: vpnConfig.cooldownMs ?? 0 });
202
611
  }
612
+ });
203
613
 
204
- const ready = yield* waitForVpn(driver, vpnConfig.connectTimeoutMs ?? 30000, runCommand);
205
- if (!ready) {
206
- return yield* new PrerequisiteRunError({
207
- message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
208
- hint: missingVpnToolHint(driver),
209
- });
210
- }
614
+ const acquireResult = yield* acquirePrerequisites.pipe(Effect.result);
615
+ const cleanup = cleanupHeldLeases(heldLeases, runCommand);
211
616
 
212
- const cleanup = prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
213
- if (cleanup === "stop-if-started") {
214
- startedDrivers.push({ driver, cooldownMs: vpnConfig.cooldownMs ?? 0 });
215
- }
617
+ if (Result.isFailure(acquireResult)) {
618
+ yield* cleanup.pipe(Effect.ignore);
619
+ return yield* Effect.fail(acquireResult.failure);
216
620
  }
217
621
 
218
- const cleanup = Effect.gen(function* () {
219
- for (const started of startedDrivers.toReversed()) {
220
- if (started.cooldownMs > 0) {
221
- yield* Effect.sleep(Duration.millis(started.cooldownMs));
222
- }
223
-
224
- const stopCommand = makeVpnCommand(started.driver, "stop");
225
- yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
226
- }
227
- });
228
-
229
622
  return yield* effect.pipe(Effect.ensuring(cleanup));
230
623
  }).pipe(Effect.result);
231
624
 
@@ -1,4 +1,4 @@
1
- import type { Effect } from "effect";
1
+ import { Schema, type Effect } from "effect";
2
2
  import type { ChildProcess } from "effect/unstable/process";
3
3
 
4
4
  import type {
@@ -23,6 +23,9 @@ export type VpnDriverResolution =
23
23
  | { success: true; driver: ResolvedVpnDriver }
24
24
  | { success: false; error: string; hint: string };
25
25
 
26
+ export const VpnCleanupPolicy = Schema.Literals(["leave-running", "stop-if-started"]);
27
+ export type VpnCleanupPolicy = Schema.Schema.Type<typeof VpnCleanupPolicy>;
28
+
26
29
  export type PrerequisiteCommandResult = {
27
30
  readonly stdout: string;
28
31
  readonly stderr: string;
@@ -33,3 +36,28 @@ export type PrerequisiteCommandRunner<E> = (
33
36
  command: ChildProcess.Command,
34
37
  label: string,
35
38
  ) => Effect.Effect<PrerequisiteCommandResult, E, never>;
39
+
40
+ export type VpnLease = {
41
+ readonly pid: number;
42
+ readonly createdAt: number;
43
+ readonly updatedAt: number;
44
+ };
45
+
46
+ export type VpnStartState = {
47
+ readonly pid: number;
48
+ readonly startedAt: number;
49
+ };
50
+
51
+ export type VpnLockOwner = {
52
+ readonly pid: number;
53
+ readonly createdAt: number;
54
+ };
55
+
56
+ export type VpnLeaseHandle = {
57
+ readonly directory: string;
58
+ readonly leasePath: string;
59
+ readonly statePath: string;
60
+ readonly lockPath: string;
61
+ readonly ttlMs: number;
62
+ readonly lockTimeoutMs: number;
63
+ };