ask-pro 0.1.71 → 0.2.78

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,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, randomUUID } from "node:crypto";
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 profileDirUnder(askProStateDir(env), agentId);
9
+ return askProBrowserProfileDirUnder(askProStateDir(env), agentId);
13
10
  }
14
- export function legacyAskProBrowserProfileDirForAgentId(agentId, homeDir = os.homedir()) {
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 (!RESOLVED_AGENT_ID_PATTERN.test(agentId)) {
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 RESOLVED_AGENT_ID_PATTERN.test(agentId) ? agentId : null;
45
+ return isResolvedAskProAgentId(agentId) ? agentId : null;
177
46
  }
178
- function askProStateDir(env = process.env) {
179
- const codexHome = env.CODEX_HOME?.trim();
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 sameLegacyIdentity(left, right) {
222
- return left.dev === right.dev && left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
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
- function isSharingDenied(error) {
225
- return ["EACCES", "EBUSY", "EPERM"].includes(error.code ?? "");
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
- async function waitForConcurrentMigration(target, legacy) {
228
- const marker = path.join(target, MIGRATION_MARKER);
229
- const owner = await fs
230
- .readFile(marker, "utf8")
231
- .then((raw) => JSON.parse(raw))
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
- if (await exists(legacy))
250
- return false;
251
- await fs.rm(marker, { force: true }).catch(() => undefined);
252
- return exists(target);
69
+ return null;
253
70
  }
254
- async function legacyHasMigrationLock(legacy, lockId) {
255
- const activeLockId = await fs
256
- .readFile(path.join(legacy, "ask-pro-automation.lock"), "utf8")
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,30 +168,18 @@ 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
- const lockPath = path.join(userDataDir, ASK_PRO_PROFILE_LOCK_FILENAME);
173
- let lockPayload;
174
+ let raw = null;
174
175
  try {
175
- lockPayload = await readFile(lockPath, "utf8");
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;
197
185
  const pid = await readChromePid(userDataDir);
@@ -224,12 +212,7 @@ export async function acquireProfileRunLock(userDataDir, options) {
224
212
  createdAt: new Date().toISOString(),
225
213
  sessionId: options.sessionId,
226
214
  };
227
- if (options.requireExistingProfile) {
228
- await stat(userDataDir);
229
- }
230
- else {
231
- await mkdir(path.dirname(lockPath), { recursive: true });
232
- }
215
+ await mkdir(path.dirname(lockPath), { recursive: true });
233
216
  await writeFile(lockPath, JSON.stringify(payload), { encoding: "utf8", flag: "wx" });
234
217
  options.logger?.(`Acquired ask-pro profile lock at ${lockPath}`);
235
218
  return {
@@ -249,18 +232,12 @@ export async function acquireProfileRunLock(userDataDir, options) {
249
232
  await delay(200);
250
233
  existing = parseProfileRunLock(await readFile(lockPath, "utf8").catch(() => null));
251
234
  if (!existing) {
252
- if (options.staleLockMode === "fail") {
253
- throw new Error("ask-pro profile lock is unreadable; refusing stale lock removal.");
254
- }
255
235
  options.logger?.("ask-pro profile lock unreadable; deleting lockfile.");
256
236
  await rm(lockPath, { force: true }).catch(() => undefined);
257
237
  continue;
258
238
  }
259
239
  }
260
240
  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
241
  await rm(lockPath, { force: true }).catch(() => undefined);
265
242
  continue;
266
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-pro",
3
- "version": "0.1.71",
3
+ "version": "0.2.78",
4
4
  "description": "Browser-backed ChatGPT Pro escalation for hard engineering questions.",
5
5
  "keywords": [
6
6
  "agents",
@@ -41,6 +41,7 @@
41
41
  "dist/src/browser/liveTabs.js",
42
42
  "dist/src/browser/modelStrategy.js",
43
43
  "dist/src/browser/pageActions.js",
44
+ "dist/src/browser/profileMigration.js",
44
45
  "dist/src/browser/profilePaths.js",
45
46
  "dist/src/browser/profileState.js",
46
47
  "dist/src/browser/providerDomFlow.js",
@@ -51,10 +52,9 @@
51
52
  "dist/src/browser/providers/chatgptDomProvider.js",
52
53
  "dist/src/browserMode.js",
53
54
  "dist/src/version.js",
54
- "scripts/refresh-local-plugin.mjs",
55
- "scripts/refresh-local-plugin.ps1",
56
55
  ".codex-plugin/plugin.json",
57
56
  "assets/ask-pro_logo.png",
57
+ "SKILL.md",
58
58
  "skills/ask-pro/SKILL.md",
59
59
  "README.md",
60
60
  "LICENSE"
@@ -72,7 +72,6 @@
72
72
  "format:diff": "oxfmt --write . && git --no-pager diff",
73
73
  "lint": "pnpm run typecheck && oxlint",
74
74
  "lint:fix": "oxlint --fix && pnpm run format",
75
- "plugin:refresh": "pnpm run build && node scripts/refresh-local-plugin.mjs",
76
75
  "test": "pnpm run test:ask-pro",
77
76
  "test:ask-pro": "vitest run tests/ask-pro tests/browser",
78
77
  "test:coverage": "vitest run --coverage",