ask-pro 0.1.71 → 0.2.80
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/.codex-plugin/plugin.json +1 -1
- package/README.md +27 -38
- package/SKILL.md +229 -0
- package/dist/bin/ask-pro-cli.js +10 -3
- package/dist/src/ask-pro/browserRunner.js +9 -10
- package/dist/src/browser/chromeLifecycle.js +4 -2
- package/dist/src/browser/index.js +1 -1
- package/dist/src/browser/profileMigration.js +299 -0
- package/dist/src/browser/profilePaths.js +27 -213
- package/dist/src/browser/profileState.js +29 -29
- package/dist/src/browser/reattach.js +3 -1
- package/package.json +3 -4
- package/skills/ask-pro/SKILL.md +4 -229
- package/scripts/refresh-local-plugin.mjs +0 -179
- package/scripts/refresh-local-plugin.ps1 +0 -93
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { askProBrowserProfileDirForAgentId, askProBrowserProfileDirUnder, askProStateDir, isResolvedAskProAgentId, legacyAskProStateDirs, } from "./profilePaths.js";
|
|
6
|
+
import { acquireProfileRunLock, isBrowserProfileInUse, isProcessAlive, releaseProfileRunLock, } from "./profileState.js";
|
|
7
|
+
// Coordination files live in the current state folder: the migration lock, records of runs that
|
|
8
|
+
// still use an old profile, unfinished copies and the profiles already reported as leftovers.
|
|
9
|
+
const MIGRATION_DIR = ".migration";
|
|
10
|
+
const PROFILE_LOCK_FILENAME = "ask-pro-automation.lock";
|
|
11
|
+
const MIGRATION_LOCK_TIMEOUT_MS = 15 * 60_000;
|
|
12
|
+
const TRASH_PREFIX = ".ask-pro-trash-";
|
|
13
|
+
/**
|
|
14
|
+
* Moves browser profiles left by earlier versions into the current state folder. A profile that
|
|
15
|
+
* is in use stays where it is and moves on a later run. Never fails the caller.
|
|
16
|
+
*/
|
|
17
|
+
export async function migrateLegacyAskProProfiles(options = {}) {
|
|
18
|
+
const env = options.env ?? process.env;
|
|
19
|
+
const stateDir = askProStateDir(env);
|
|
20
|
+
try {
|
|
21
|
+
const profiles = await findLegacyProfiles(env);
|
|
22
|
+
if (profiles.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
if (await someMissing(profiles.map((profile) => profile.target))) {
|
|
25
|
+
await withMigrationLock(stateDir, options.lockTimeoutMs, async () => {
|
|
26
|
+
// Only the newest old copy of each profile moves, even if it must wait for a later run.
|
|
27
|
+
const claimed = new Set();
|
|
28
|
+
for (const profile of profiles) {
|
|
29
|
+
if (claimed.has(profile.target))
|
|
30
|
+
continue;
|
|
31
|
+
claimed.add(profile.target);
|
|
32
|
+
if (!(await exists(profile.target)))
|
|
33
|
+
await moveProfile(profile, stateDir);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
await reportLeftovers(profiles, stateDir, options.log);
|
|
38
|
+
await removeEmptyFolders(profiles);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// retried on a later run
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The browser profile this run uses: the current one, or an earlier version's profile that is
|
|
46
|
+
* still in use and has not moved yet. The old location is then kept for the whole run.
|
|
47
|
+
*/
|
|
48
|
+
export async function prepareAskProBrowserProfile(agentId, options = {}) {
|
|
49
|
+
const env = options.env ?? process.env;
|
|
50
|
+
await migrateLegacyAskProProfiles(options);
|
|
51
|
+
const target = askProBrowserProfileDirForAgentId(agentId, env);
|
|
52
|
+
if (await exists(target))
|
|
53
|
+
return target;
|
|
54
|
+
const stateDir = askProStateDir(env);
|
|
55
|
+
const pick = async (record) => {
|
|
56
|
+
if (await exists(target))
|
|
57
|
+
return target;
|
|
58
|
+
for (const root of legacyAskProStateDirs(env)) {
|
|
59
|
+
const legacy = askProBrowserProfileDirUnder(root, agentId);
|
|
60
|
+
if (!(await exists(legacy)))
|
|
61
|
+
continue;
|
|
62
|
+
if (record)
|
|
63
|
+
await recordLegacyUse(stateDir, legacy);
|
|
64
|
+
return legacy;
|
|
65
|
+
}
|
|
66
|
+
return target;
|
|
67
|
+
};
|
|
68
|
+
// The record keeps a concurrent migration from moving the profile out from under this run.
|
|
69
|
+
return withMigrationLock(stateDir, MIGRATION_LOCK_TIMEOUT_MS, () => pick(true)).catch(() => pick(false));
|
|
70
|
+
}
|
|
71
|
+
async function findLegacyProfiles(env) {
|
|
72
|
+
const stateDir = askProStateDir(env);
|
|
73
|
+
const profiles = [];
|
|
74
|
+
for (const root of legacyAskProStateDirs(env)) {
|
|
75
|
+
const entries = await fs.readdir(root).catch(() => []);
|
|
76
|
+
if (entries.length === 0)
|
|
77
|
+
continue;
|
|
78
|
+
for (const entry of entries.filter((name) => name.startsWith(TRASH_PREFIX))) {
|
|
79
|
+
await fs.rm(path.join(root, entry), { recursive: true, force: true }).catch(() => undefined);
|
|
80
|
+
}
|
|
81
|
+
const agentIds = entries.includes("agents")
|
|
82
|
+
? (await fs.readdir(path.join(root, "agents")).catch(() => [])).filter(isResolvedAskProAgentId)
|
|
83
|
+
: [];
|
|
84
|
+
for (const agentId of [null, ...agentIds]) {
|
|
85
|
+
const legacy = askProBrowserProfileDirUnder(root, agentId);
|
|
86
|
+
if (await isRealFolder(legacy)) {
|
|
87
|
+
profiles.push({ root, legacy, target: askProBrowserProfileDirUnder(stateDir, agentId) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return profiles;
|
|
92
|
+
}
|
|
93
|
+
async function moveProfile(profile, stateDir) {
|
|
94
|
+
const { legacy, target } = profile;
|
|
95
|
+
if (await hasLiveLegacyUse(stateDir, legacy))
|
|
96
|
+
return;
|
|
97
|
+
let lock;
|
|
98
|
+
try {
|
|
99
|
+
// Holding the profile's run lock keeps other ask-pro runs, old versions included, from
|
|
100
|
+
// opening it mid-move.
|
|
101
|
+
lock = await acquireProfileRunLock(legacy, { timeoutMs: 1, pollMs: 1 });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!lock)
|
|
107
|
+
return;
|
|
108
|
+
let lockPath = lock.path;
|
|
109
|
+
try {
|
|
110
|
+
if (await isBrowserProfileInUse(legacy, { ignoreLockId: lock.lockId }))
|
|
111
|
+
return;
|
|
112
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
113
|
+
try {
|
|
114
|
+
await fs.rename(legacy, target);
|
|
115
|
+
lockPath = path.join(target, PROFILE_LOCK_FILENAME);
|
|
116
|
+
await linkOldPath(legacy, target);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// Anything but a move across volumes (on Windows: a profile Chrome still has open) is
|
|
121
|
+
// retried on a later run.
|
|
122
|
+
if (error.code !== "EXDEV")
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await copyProfile(legacy, target, stateDir);
|
|
126
|
+
await removeFolder(legacy, profile.root, () => linkOldPath(legacy, target));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// retried on a later run
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await releaseProfileRunLock(lockPath, lock.lockId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function copyProfile(legacy, target, stateDir) {
|
|
136
|
+
const staging = path.join(stateDir, MIGRATION_DIR, `copy-${randomUUID()}`);
|
|
137
|
+
const ownLock = path.join(legacy, PROFILE_LOCK_FILENAME);
|
|
138
|
+
try {
|
|
139
|
+
await fs.cp(legacy, staging, {
|
|
140
|
+
recursive: true,
|
|
141
|
+
errorOnExist: true,
|
|
142
|
+
force: false,
|
|
143
|
+
verbatimSymlinks: true,
|
|
144
|
+
filter: (source) => source !== ownLock,
|
|
145
|
+
});
|
|
146
|
+
const [expected, copied] = await Promise.all([listTree(legacy), listTree(staging)]);
|
|
147
|
+
expected.delete(PROFILE_LOCK_FILENAME);
|
|
148
|
+
if (expected.size !== copied.size ||
|
|
149
|
+
[...expected].some(([name, kind]) => copied.get(name) !== kind)) {
|
|
150
|
+
throw new Error("ask-pro browser profile copy is incomplete.");
|
|
151
|
+
}
|
|
152
|
+
await fs.rename(staging, target);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Relative path -> entry kind, with file sizes.
|
|
159
|
+
async function listTree(root) {
|
|
160
|
+
const tree = new Map();
|
|
161
|
+
const walk = async (dir) => {
|
|
162
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
163
|
+
const full = path.join(dir, entry.name);
|
|
164
|
+
const name = path.relative(root, full);
|
|
165
|
+
if (entry.isDirectory()) {
|
|
166
|
+
tree.set(name, "dir");
|
|
167
|
+
await walk(full);
|
|
168
|
+
}
|
|
169
|
+
else if (entry.isSymbolicLink()) {
|
|
170
|
+
tree.set(name, `link:${await fs.readlink(full)}`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
tree.set(name, `file:${(await fs.lstat(full)).size}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
await walk(root);
|
|
178
|
+
return tree;
|
|
179
|
+
}
|
|
180
|
+
// An older ask-pro install that has not been updated yet keeps opening the old path. A link
|
|
181
|
+
// there sends it to the moved profile, so it stays signed in and shares the same run lock.
|
|
182
|
+
async function linkOldPath(legacy, target) {
|
|
183
|
+
const type = process.platform === "win32" ? "junction" : "dir";
|
|
184
|
+
await fs.symlink(target, legacy, type).catch(() => undefined);
|
|
185
|
+
}
|
|
186
|
+
// Moves the folder aside in one step, so a partly deleted profile is never left behind, and runs
|
|
187
|
+
// `afterMove` before the slow delete. A folder that cannot be moved stays and is reported as a
|
|
188
|
+
// leftover.
|
|
189
|
+
async function removeFolder(folder, root, afterMove) {
|
|
190
|
+
const trash = path.join(root, `${TRASH_PREFIX}${randomUUID()}`);
|
|
191
|
+
try {
|
|
192
|
+
await fs.rename(folder, trash);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
await afterMove();
|
|
198
|
+
await fs.rm(trash, { recursive: true, force: true }).catch(() => undefined);
|
|
199
|
+
}
|
|
200
|
+
async function withMigrationLock(stateDir, timeoutMs = MIGRATION_LOCK_TIMEOUT_MS, action) {
|
|
201
|
+
const dir = path.join(stateDir, MIGRATION_DIR);
|
|
202
|
+
const lock = await acquireProfileRunLock(dir, {
|
|
203
|
+
timeoutMs,
|
|
204
|
+
pollMs: 200,
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
// Copies left by an interrupted migration; none can be in progress while the lock is held.
|
|
208
|
+
for (const entry of await fs.readdir(dir)) {
|
|
209
|
+
if (entry.startsWith("copy-")) {
|
|
210
|
+
await fs.rm(path.join(dir, entry), { recursive: true, force: true }).catch(() => undefined);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return await action();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await lock?.release();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function recordLegacyUse(stateDir, legacy) {
|
|
220
|
+
const record = path.join(stateDir, MIGRATION_DIR, `in-use-${process.pid}-${randomUUID()}.json`);
|
|
221
|
+
await fs.writeFile(record, JSON.stringify({ pid: process.pid, profile: legacy }));
|
|
222
|
+
process.once("exit", () => rmSync(record, { force: true }));
|
|
223
|
+
}
|
|
224
|
+
async function hasLiveLegacyUse(stateDir, legacy) {
|
|
225
|
+
const dir = path.join(stateDir, MIGRATION_DIR);
|
|
226
|
+
let inUse = false;
|
|
227
|
+
for (const entry of await fs.readdir(dir).catch(() => [])) {
|
|
228
|
+
if (!entry.startsWith("in-use-"))
|
|
229
|
+
continue;
|
|
230
|
+
const record = path.join(dir, entry);
|
|
231
|
+
const { pid, profile } = await fs
|
|
232
|
+
.readFile(record, "utf8")
|
|
233
|
+
.then((raw) => JSON.parse(raw))
|
|
234
|
+
.catch(() => ({ pid: undefined, profile: undefined }));
|
|
235
|
+
if (!pid || !isProcessAlive(pid)) {
|
|
236
|
+
await fs.rm(record, { force: true }).catch(() => undefined);
|
|
237
|
+
}
|
|
238
|
+
else if (profile && samePath(profile, legacy)) {
|
|
239
|
+
inUse = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return inUse;
|
|
243
|
+
}
|
|
244
|
+
// Reports once per folder an old profile that stays behind because a current one already exists.
|
|
245
|
+
async function reportLeftovers(profiles, stateDir, log) {
|
|
246
|
+
if (!log)
|
|
247
|
+
return;
|
|
248
|
+
const leftovers = [];
|
|
249
|
+
for (const profile of profiles) {
|
|
250
|
+
if ((await isRealFolder(profile.legacy)) && (await exists(profile.target))) {
|
|
251
|
+
leftovers.push(profile);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (leftovers.length === 0)
|
|
255
|
+
return;
|
|
256
|
+
const file = path.join(stateDir, MIGRATION_DIR, "reported-leftovers.json");
|
|
257
|
+
const reported = await fs
|
|
258
|
+
.readFile(file, "utf8")
|
|
259
|
+
.then((raw) => JSON.parse(raw))
|
|
260
|
+
.catch(() => []);
|
|
261
|
+
const fresh = leftovers.filter((profile) => !reported.includes(profile.legacy));
|
|
262
|
+
if (fresh.length === 0)
|
|
263
|
+
return;
|
|
264
|
+
for (const { legacy, target } of fresh) {
|
|
265
|
+
log(`ask-pro now uses ${target}. Once no Chrome window or older ask-pro uses ${legacy}, you can delete it.`);
|
|
266
|
+
}
|
|
267
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
268
|
+
await fs.writeFile(file, JSON.stringify([...reported, ...fresh.map((profile) => profile.legacy)]));
|
|
269
|
+
}
|
|
270
|
+
// Removes old state folders the migration emptied. Folders that still hold anything stay.
|
|
271
|
+
async function removeEmptyFolders(profiles) {
|
|
272
|
+
for (const root of new Set(profiles.map((profile) => profile.root))) {
|
|
273
|
+
const agents = path.join(root, "agents");
|
|
274
|
+
for (const agentId of await fs.readdir(agents).catch(() => [])) {
|
|
275
|
+
if (isResolvedAskProAgentId(agentId)) {
|
|
276
|
+
await fs.rmdir(path.join(agents, agentId)).catch(() => undefined);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await fs.rmdir(agents).catch(() => undefined);
|
|
280
|
+
await fs.rmdir(root).catch(() => undefined);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function someMissing(paths) {
|
|
284
|
+
for (const candidate of paths)
|
|
285
|
+
if (!(await exists(candidate)))
|
|
286
|
+
return true;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
// Not the link an earlier move leaves at the old path.
|
|
290
|
+
async function isRealFolder(candidate) {
|
|
291
|
+
return fs.lstat(candidate).then((info) => info.isDirectory(), () => false);
|
|
292
|
+
}
|
|
293
|
+
async function exists(candidate) {
|
|
294
|
+
return fs.stat(candidate).then((info) => info.isDirectory(), () => false);
|
|
295
|
+
}
|
|
296
|
+
function samePath(left, right) {
|
|
297
|
+
const normalize = (value) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
298
|
+
return normalize(left) === normalize(right);
|
|
299
|
+
}
|
|
@@ -1,148 +1,17 @@
|
|
|
1
|
-
import { createHash
|
|
2
|
-
import fs from "node:fs/promises";
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
3
2
|
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
|
-
import { acquireProfileRunLock, isBrowserProfileInUse, isProcessAlive, releaseProfileRunLock, } from "./profileState.js";
|
|
6
4
|
const RESOLVED_AGENT_ID_PATTERN = /^[a-z0-9._-]+-[a-f0-9]{10}$/;
|
|
7
|
-
const MIGRATION_MARKER = ".ask-pro-profile-migration";
|
|
8
5
|
export function defaultAskProBrowserProfileDir() {
|
|
9
6
|
return askProBrowserProfileDirForAgentId(null);
|
|
10
7
|
}
|
|
11
8
|
export function askProBrowserProfileDirForAgentId(agentId, env = process.env) {
|
|
12
|
-
return
|
|
9
|
+
return askProBrowserProfileDirUnder(askProStateDir(env), agentId);
|
|
13
10
|
}
|
|
14
|
-
export function
|
|
15
|
-
return profileDirUnder(path.join(homeDir, ".agents", "skills", "ask-pro"), agentId);
|
|
16
|
-
}
|
|
17
|
-
export async function ensureAskProBrowserProfileDir(agentId, options = {}) {
|
|
18
|
-
const target = askProBrowserProfileDirForAgentId(agentId, options.env);
|
|
19
|
-
const legacy = legacyAskProBrowserProfileDirForAgentId(agentId, options.homeDir);
|
|
20
|
-
const [targetExists, legacyExists] = await Promise.all([exists(target), exists(legacy)]);
|
|
21
|
-
if (targetExists && legacyExists) {
|
|
22
|
-
const recordedIdentity = await retainedLegacyIdentity(target);
|
|
23
|
-
if (recordedIdentity) {
|
|
24
|
-
const currentIdentity = await legacyProfileIdentity(legacy);
|
|
25
|
-
if (currentIdentity && sameLegacyIdentity(currentIdentity, recordedIdentity)) {
|
|
26
|
-
if (await waitForConcurrentMigration(target, legacy))
|
|
27
|
-
return target;
|
|
28
|
-
if (await isBrowserProfileInUse(legacy)) {
|
|
29
|
-
throw new Error("Legacy ask-pro browser profile is in use; retry after its browser run exits.");
|
|
30
|
-
}
|
|
31
|
-
return target;
|
|
32
|
-
}
|
|
33
|
-
throw new Error(`Both current and legacy ask-pro browser profiles exist; refusing to merge ${legacy} into ${target}.`);
|
|
34
|
-
}
|
|
35
|
-
if (await waitForConcurrentMigration(target, legacy))
|
|
36
|
-
return target;
|
|
37
|
-
if ((await exists(target)) && !(await exists(legacy)))
|
|
38
|
-
return target;
|
|
39
|
-
throw new Error(`Both current and legacy ask-pro browser profiles exist; refusing to merge ${legacy} into ${target}.`);
|
|
40
|
-
}
|
|
41
|
-
if (targetExists || !legacyExists)
|
|
42
|
-
return target;
|
|
43
|
-
if (await isBrowserProfileInUse(legacy)) {
|
|
44
|
-
throw new Error("Legacy ask-pro browser profile is in use; retry after its browser run exits.");
|
|
45
|
-
}
|
|
46
|
-
let migrationLock;
|
|
47
|
-
try {
|
|
48
|
-
migrationLock = await acquireProfileRunLock(legacy, {
|
|
49
|
-
timeoutMs: 300_000,
|
|
50
|
-
pollMs: 100,
|
|
51
|
-
requireExistingProfile: true,
|
|
52
|
-
staleLockMode: "fail",
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
catch (error) {
|
|
56
|
-
if ((await exists(target)) && !(await exists(legacy)))
|
|
57
|
-
return target;
|
|
58
|
-
throw new Error("Legacy ask-pro browser profile could not be claimed; retry after other browser runs exit.", { cause: error });
|
|
59
|
-
}
|
|
60
|
-
if (!migrationLock)
|
|
61
|
-
throw new Error("Could not claim the legacy ask-pro browser profile.");
|
|
62
|
-
let releasePath = migrationLock.path;
|
|
63
|
-
try {
|
|
64
|
-
if (await matchesRetainedLegacy(target, legacy))
|
|
65
|
-
return target;
|
|
66
|
-
if (await isBrowserProfileInUse(legacy, { ignoreLockId: migrationLock.lockId })) {
|
|
67
|
-
throw new Error("Legacy ask-pro browser profile is in use; retry after its browser run exits.");
|
|
68
|
-
}
|
|
69
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
70
|
-
let identityRequired = false;
|
|
71
|
-
try {
|
|
72
|
-
await fs.rename(legacy, target);
|
|
73
|
-
releasePath = path.join(target, path.basename(migrationLock.path));
|
|
74
|
-
return target;
|
|
75
|
-
}
|
|
76
|
-
catch (error) {
|
|
77
|
-
const code = error.code;
|
|
78
|
-
if (code !== "EXDEV" && !isSharingDenied(error)) {
|
|
79
|
-
if ((await exists(target)) && !(await exists(legacy)))
|
|
80
|
-
return target;
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
|
-
identityRequired = code !== "EXDEV";
|
|
84
|
-
}
|
|
85
|
-
const legacyIdentity = await legacyProfileIdentity(legacy);
|
|
86
|
-
if (identityRequired && !legacyIdentity) {
|
|
87
|
-
throw new Error("Legacy ask-pro browser profile identity is unavailable; refusing to copy.");
|
|
88
|
-
}
|
|
89
|
-
const staging = `${target}.migrating-${process.pid}-${randomUUID()}`;
|
|
90
|
-
try {
|
|
91
|
-
await fs.cp(legacy, staging, { recursive: true, errorOnExist: true });
|
|
92
|
-
await fs.writeFile(path.join(staging, MIGRATION_MARKER), JSON.stringify({
|
|
93
|
-
pid: process.pid,
|
|
94
|
-
createdAt: Date.now(),
|
|
95
|
-
migrationLockId: migrationLock.lockId,
|
|
96
|
-
...(legacyIdentity ? { legacyIdentity } : {}),
|
|
97
|
-
}));
|
|
98
|
-
await fs.rename(staging, target);
|
|
99
|
-
releasePath = path.join(target, path.basename(migrationLock.path));
|
|
100
|
-
}
|
|
101
|
-
catch (error) {
|
|
102
|
-
await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
103
|
-
if ((await exists(target)) && !(await exists(legacy)))
|
|
104
|
-
return target;
|
|
105
|
-
if (await matchesRetainedLegacy(target, legacy))
|
|
106
|
-
return target;
|
|
107
|
-
if (await waitForConcurrentMigration(target, legacy))
|
|
108
|
-
return target;
|
|
109
|
-
if ((await exists(target)) && !(await exists(legacy)))
|
|
110
|
-
return target;
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
await fs.rm(legacy, { recursive: true });
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
await releaseProfileRunLock(migrationLock.path, migrationLock.lockId);
|
|
118
|
-
if (isSharingDenied(error) && legacyIdentity)
|
|
119
|
-
return target;
|
|
120
|
-
await fs.rm(path.join(target, MIGRATION_MARKER), { force: true });
|
|
121
|
-
if (isSharingDenied(error)) {
|
|
122
|
-
throw new Error("Legacy ask-pro browser profile could not be removed and has no reliable identity; refusing to retain it.", { cause: error });
|
|
123
|
-
}
|
|
124
|
-
throw error;
|
|
125
|
-
}
|
|
126
|
-
await fs.rm(path.join(target, MIGRATION_MARKER), { force: true });
|
|
127
|
-
return target;
|
|
128
|
-
}
|
|
129
|
-
finally {
|
|
130
|
-
await releaseProfileRunLock(releasePath, migrationLock.lockId);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
export function askProAgentIdForLegacyBrowserProfileDir(profileDir) {
|
|
134
|
-
return agentIdForProfileDir(profileDir, path.join(os.homedir(), ".agents", "skills", "ask-pro"));
|
|
135
|
-
}
|
|
136
|
-
export function isLegacyAskProManagedBrowserProfileDir(profileDir) {
|
|
137
|
-
const resolved = normalizeProfilePath(profileDir);
|
|
138
|
-
if (resolved === normalizeProfilePath(legacyAskProBrowserProfileDirForAgentId(null)))
|
|
139
|
-
return true;
|
|
140
|
-
return askProAgentIdForLegacyBrowserProfileDir(profileDir) !== null;
|
|
141
|
-
}
|
|
142
|
-
function profileDirUnder(root, agentId) {
|
|
11
|
+
export function askProBrowserProfileDirUnder(root, agentId) {
|
|
143
12
|
if (!agentId)
|
|
144
13
|
return path.join(root, "browser-profile");
|
|
145
|
-
if (!
|
|
14
|
+
if (!isResolvedAskProAgentId(agentId)) {
|
|
146
15
|
throw new Error("Stored ask-pro agent id is invalid.");
|
|
147
16
|
}
|
|
148
17
|
return path.join(root, "agents", agentId, "browser-profile");
|
|
@@ -173,90 +42,35 @@ function agentIdForProfileDir(profileDir, stateRoot) {
|
|
|
173
42
|
if (parts.length !== 2 || parts[1] !== "browser-profile")
|
|
174
43
|
return null;
|
|
175
44
|
const agentId = parts[0];
|
|
176
|
-
return
|
|
45
|
+
return isResolvedAskProAgentId(agentId) ? agentId : null;
|
|
177
46
|
}
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
return path.join(codexHome ? path.resolve(codexHome) : path.join(os.homedir(), ".codex"), "state", "ask-pro");
|
|
181
|
-
}
|
|
182
|
-
async function exists(filePath) {
|
|
183
|
-
return fs
|
|
184
|
-
.stat(filePath)
|
|
185
|
-
.then(() => true)
|
|
186
|
-
.catch((error) => {
|
|
187
|
-
if (error.code === "ENOENT")
|
|
188
|
-
return false;
|
|
189
|
-
throw error;
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
async function legacyProfileIdentity(profileDir) {
|
|
193
|
-
const info = await fs.stat(profileDir, { bigint: true }).catch(() => null);
|
|
194
|
-
if (!info?.isDirectory() || info.dev <= 0n || info.ino <= 0n || info.birthtimeNs <= 0n) {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
return {
|
|
198
|
-
dev: info.dev.toString(),
|
|
199
|
-
ino: info.ino.toString(),
|
|
200
|
-
birthtimeNs: info.birthtimeNs.toString(),
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
async function retainedLegacyIdentity(target) {
|
|
204
|
-
const recorded = await fs
|
|
205
|
-
.readFile(path.join(target, MIGRATION_MARKER), "utf8")
|
|
206
|
-
.then((raw) => JSON.parse(raw).legacyIdentity)
|
|
207
|
-
.catch(() => null);
|
|
208
|
-
if (!recorded ||
|
|
209
|
-
![recorded.dev, recorded.ino, recorded.birthtimeNs].every((value) => /^[1-9]\d*$/.test(value))) {
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
return recorded;
|
|
213
|
-
}
|
|
214
|
-
async function matchesRetainedLegacy(target, legacy) {
|
|
215
|
-
const [recorded, current] = await Promise.all([
|
|
216
|
-
retainedLegacyIdentity(target),
|
|
217
|
-
legacyProfileIdentity(legacy),
|
|
218
|
-
]);
|
|
219
|
-
return recorded !== null && current !== null && sameLegacyIdentity(recorded, current);
|
|
47
|
+
export function isResolvedAskProAgentId(agentId) {
|
|
48
|
+
return RESOLVED_AGENT_ID_PATTERN.test(agentId);
|
|
220
49
|
}
|
|
221
|
-
function
|
|
222
|
-
|
|
50
|
+
export function askProStateDir(env = process.env) {
|
|
51
|
+
const override = env.ASK_PRO_STATE_DIR?.trim();
|
|
52
|
+
return override ? path.resolve(override) : path.join(os.homedir(), ".agents", "state", "ask-pro");
|
|
223
53
|
}
|
|
224
|
-
|
|
225
|
-
|
|
54
|
+
// State folders of earlier versions, in the order profiles are migrated from them.
|
|
55
|
+
export function legacyAskProStateDirs(env = process.env) {
|
|
56
|
+
const codexHome = env.CODEX_HOME?.trim();
|
|
57
|
+
const current = normalizeProfilePath(askProStateDir(env));
|
|
58
|
+
return [
|
|
59
|
+
path.join(codexHome ? path.resolve(codexHome) : path.join(os.homedir(), ".codex"), "state", "ask-pro"),
|
|
60
|
+
path.join(os.homedir(), ".agents", "skills", "ask-pro"),
|
|
61
|
+
].filter((dir) => normalizeProfilePath(dir) !== current);
|
|
226
62
|
}
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
.catch(() => null);
|
|
233
|
-
if (!owner?.pid ||
|
|
234
|
-
!Number.isFinite(owner.pid) ||
|
|
235
|
-
!owner.createdAt ||
|
|
236
|
-
!Number.isFinite(owner.createdAt) ||
|
|
237
|
-
!owner.migrationLockId) {
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
while (isProcessAlive(owner.pid) &&
|
|
241
|
-
Date.now() - owner.createdAt < 300_000 &&
|
|
242
|
-
(await legacyHasMigrationLock(legacy, owner.migrationLockId))) {
|
|
243
|
-
if (!(await exists(legacy))) {
|
|
244
|
-
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
245
|
-
return true;
|
|
246
|
-
}
|
|
247
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
63
|
+
export function askProAgentIdForLegacyBrowserProfileDir(profileDir) {
|
|
64
|
+
for (const root of legacyAskProStateDirs()) {
|
|
65
|
+
const agentId = agentIdForProfileDir(profileDir, root);
|
|
66
|
+
if (agentId)
|
|
67
|
+
return agentId;
|
|
248
68
|
}
|
|
249
|
-
|
|
250
|
-
return false;
|
|
251
|
-
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
252
|
-
return exists(target);
|
|
69
|
+
return null;
|
|
253
70
|
}
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
.then((raw) => JSON.parse(raw).lockId)
|
|
258
|
-
.catch(() => null);
|
|
259
|
-
return activeLockId === lockId;
|
|
71
|
+
export function isLegacyAskProManagedBrowserProfileDir(profileDir) {
|
|
72
|
+
const resolved = normalizeProfilePath(profileDir);
|
|
73
|
+
return (legacyAskProStateDirs().some((root) => normalizeProfilePath(askProBrowserProfileDirUnder(root, null)) === resolved) || askProAgentIdForLegacyBrowserProfileDir(profileDir) !== null);
|
|
260
74
|
}
|
|
261
75
|
export function resolveAskProAgentId(env = process.env) {
|
|
262
76
|
const value = env.ASK_PRO_AGENT_ID;
|
|
@@ -168,32 +168,30 @@ function parseProfileRunLock(payload) {
|
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
|
+
// True while an ask-pro run holds the profile or a Chrome process may still have it open. An
|
|
172
|
+
// unanswerable process scan counts as in use.
|
|
171
173
|
export async function isBrowserProfileInUse(userDataDir, options = {}) {
|
|
172
|
-
|
|
173
|
-
let lockPayload;
|
|
174
|
+
let raw = null;
|
|
174
175
|
try {
|
|
175
|
-
|
|
176
|
+
raw = await readFile(path.join(userDataDir, ASK_PRO_PROFILE_LOCK_FILENAME), "utf8");
|
|
176
177
|
}
|
|
177
178
|
catch (error) {
|
|
178
179
|
if (error.code !== "ENOENT")
|
|
179
180
|
return true;
|
|
180
|
-
lockPayload = null;
|
|
181
|
-
}
|
|
182
|
-
let lock = parseProfileRunLock(lockPayload);
|
|
183
|
-
if (lockPayload !== null && !lock) {
|
|
184
|
-
await delay(200);
|
|
185
|
-
try {
|
|
186
|
-
lockPayload = await readFile(lockPath, "utf8");
|
|
187
|
-
}
|
|
188
|
-
catch (error) {
|
|
189
|
-
if (error.code !== "ENOENT")
|
|
190
|
-
return true;
|
|
191
|
-
lockPayload = null;
|
|
192
|
-
}
|
|
193
|
-
lock = parseProfileRunLock(lockPayload);
|
|
194
181
|
}
|
|
182
|
+
const lock = parseProfileRunLock(raw);
|
|
195
183
|
if (lock && lock.lockId !== options.ignoreLockId && isProcessAlive(lock.pid))
|
|
196
184
|
return true;
|
|
185
|
+
try {
|
|
186
|
+
const leases = await readdir(path.join(userDataDir, ASK_PRO_RUN_LEASE_DIRNAME));
|
|
187
|
+
if (leases.some((name) => name.endsWith(".lease") && isProcessAlive(Number(name.match(/^(\d+)-/)?.[1])))) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code !== "ENOENT")
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
197
195
|
const pid = await readChromePid(userDataDir);
|
|
198
196
|
const port = await readDevToolsPort(userDataDir);
|
|
199
197
|
const [owner, devTools] = await Promise.all([
|
|
@@ -204,6 +202,19 @@ export async function isBrowserProfileInUse(userDataDir, options = {}) {
|
|
|
204
202
|
return true;
|
|
205
203
|
return owner === null;
|
|
206
204
|
}
|
|
205
|
+
// Call while holding the profile's launch lock; only disposable caches are removed.
|
|
206
|
+
export async function clearIdleBrowserProfileCaches(userDataDir, options = {}) {
|
|
207
|
+
try {
|
|
208
|
+
if (await isBrowserProfileInUse(userDataDir, options))
|
|
209
|
+
return;
|
|
210
|
+
for (const name of ["Cache", "Code Cache"]) {
|
|
211
|
+
await rm(path.join(userDataDir, "Default", name), { recursive: true, force: true }).catch(() => undefined);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Cache maintenance must never prevent a browser run.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
207
218
|
export async function acquireProfileRunLock(userDataDir, options) {
|
|
208
219
|
const timeoutMs = options.timeoutMs;
|
|
209
220
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
@@ -224,12 +235,7 @@ export async function acquireProfileRunLock(userDataDir, options) {
|
|
|
224
235
|
createdAt: new Date().toISOString(),
|
|
225
236
|
sessionId: options.sessionId,
|
|
226
237
|
};
|
|
227
|
-
|
|
228
|
-
await stat(userDataDir);
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
232
|
-
}
|
|
238
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
233
239
|
await writeFile(lockPath, JSON.stringify(payload), { encoding: "utf8", flag: "wx" });
|
|
234
240
|
options.logger?.(`Acquired ask-pro profile lock at ${lockPath}`);
|
|
235
241
|
return {
|
|
@@ -249,18 +255,12 @@ export async function acquireProfileRunLock(userDataDir, options) {
|
|
|
249
255
|
await delay(200);
|
|
250
256
|
existing = parseProfileRunLock(await readFile(lockPath, "utf8").catch(() => null));
|
|
251
257
|
if (!existing) {
|
|
252
|
-
if (options.staleLockMode === "fail") {
|
|
253
|
-
throw new Error("ask-pro profile lock is unreadable; refusing stale lock removal.");
|
|
254
|
-
}
|
|
255
258
|
options.logger?.("ask-pro profile lock unreadable; deleting lockfile.");
|
|
256
259
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
257
260
|
continue;
|
|
258
261
|
}
|
|
259
262
|
}
|
|
260
263
|
if (!existing || !isProcessAlive(existing.pid)) {
|
|
261
|
-
if (options.staleLockMode === "fail") {
|
|
262
|
-
throw new Error("ask-pro profile lock owner is not alive; refusing stale lock removal.");
|
|
263
|
-
}
|
|
264
264
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
265
265
|
continue;
|
|
266
266
|
}
|
|
@@ -355,7 +355,9 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
|
|
|
355
355
|
if (!reusedChrome) {
|
|
356
356
|
await seedChromeProfileLanguage(userDataDir, resolved.acceptLanguage, logger);
|
|
357
357
|
}
|
|
358
|
-
chrome =
|
|
358
|
+
chrome =
|
|
359
|
+
reusedChrome ??
|
|
360
|
+
(await launchChrome(resolved, userDataDir, logger, { ignoreLockId: launchLock?.lockId }));
|
|
359
361
|
if (manualLogin) {
|
|
360
362
|
reattachRunLease = {
|
|
361
363
|
userDataDir,
|