ask-pro 0.1.69 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-pro",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Minimal browser-backed ChatGPT Pro escalation workflow for agents via $ask-pro.",
5
5
  "author": {
6
6
  "name": "Jonathan",
package/README.md CHANGED
@@ -23,21 +23,17 @@ codex plugin add ask-pro@ask-pro
23
23
  ```
24
24
 
25
25
  Update with `codex plugin marketplace upgrade ask-pro`, then restart Codex.
26
- Both installs run the same bundled launcher and share the same state.
26
+ Both installs run the same bundled launcher. The `ask-pro` npm package
27
+ (`npm i -g ask-pro`) still works too. All three share the same state.
27
28
 
28
29
  You need Node.js 24+, npm, Chrome, and a ChatGPT account with Pro access. The
29
30
  browser runtime is developed and tested on Windows; other operating systems
30
31
  need validation.
31
32
 
32
- **Upgrading from an earlier version:** ask-pro state moved from
33
- `~/.codex/state/ask-pro` to `~/.agents/state/ask-pro`. Before the first run
34
- after upgrading, and while no ask-pro run is active, copy your signed-in browser
35
- state once:
36
-
37
- ```powershell
38
- New-Item -ItemType Directory -Force "$HOME\.agents\state" | Out-Null
39
- Copy-Item -Recurse "$HOME\.codex\state\ask-pro" "$HOME\.agents\state\ask-pro"
40
- ```
33
+ **Upgrading from an earlier version:** nothing to do. On its first run,
34
+ ask-pro moves your signed-in browser profiles from `~/.codex/state/ask-pro` to
35
+ `~/.agents/state/ask-pro`. If Chrome still has the old profile open, ask-pro
36
+ uses it where it is and moves it on a later run.
41
37
 
42
38
  ## Browser and session state
43
39
 
@@ -10,7 +10,8 @@ import { Command, Option } from "commander";
10
10
  import { createAskProSession, getAskProSessionPaths, pruneExpiredAskProSessions, readAskProAnswer, readAskProStatus, updateAskProResumeCommand, updateAskProStatus, } from "../src/ask-pro/session.js";
11
11
  import { AskProNeedsAuthError, isSuspiciousPreambleAnswer, resumeAskProBrowserSession, runAskProBrowserSession, } from "../src/ask-pro/browserRunner.js";
12
12
  import { renderToonRecord } from "../src/ask-pro/toon.js";
13
- import { askProAgentIdForManagedBrowserProfileDir, defaultAskProBrowserProfileDir, } from "../src/browser/profilePaths.js";
13
+ import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, defaultAskProBrowserProfileDir, isLegacyAskProManagedBrowserProfileDir, } from "../src/browser/profilePaths.js";
14
+ import { migrateLegacyAskProProfiles } from "../src/browser/profileMigration.js";
14
15
  import { getCliVersion } from "../src/version.js";
15
16
  const program = new Command();
16
17
  program
@@ -60,6 +61,10 @@ async function runAskPro(question, options) {
60
61
  if (options.answers && question.trim())
61
62
  throw new Error("Use --answers with --resume, without a question argument.");
62
63
  await pruneExpiredAskProSessions({ cwd });
64
+ await migrateLegacyAskProProfiles({
65
+ log: (message) => console.error(message),
66
+ lockTimeoutMs: 5_000,
67
+ });
63
68
  if (options.status !== undefined) {
64
69
  const { status } = await readAskProStatus({ cwd, sessionId: optionSessionId(options.status) });
65
70
  printStatusRecord(status, {
@@ -522,9 +527,11 @@ async function readBrowserMetadata(cwd, sessionId) {
522
527
  function profileMode(profileDir) {
523
528
  if (!profileDir)
524
529
  return undefined;
525
- if (askProAgentIdForManagedBrowserProfileDir(profileDir))
530
+ if (askProAgentIdForManagedBrowserProfileDir(profileDir) ||
531
+ askProAgentIdForLegacyBrowserProfileDir(profileDir))
526
532
  return "agent";
527
- if (path.resolve(profileDir) === path.resolve(defaultAskProBrowserProfileDir()))
533
+ if (path.resolve(profileDir) === path.resolve(defaultAskProBrowserProfileDir()) ||
534
+ isLegacyAskProManagedBrowserProfileDir(profileDir))
528
535
  return "shared";
529
536
  return undefined;
530
537
  }
@@ -5,7 +5,8 @@ import { AssistantStoppedError, BrowserAutomationError } from "../browser/errors
5
5
  import { AssistantQuestionsError } from "../browser/actions/questions.js";
6
6
  import { readAskProQuestions, parseQuestionAnswers, writeAskProQuestions } from "./questions.js";
7
7
  import { GitHubConnectionRequiredError } from "../browser/actions/github.js";
8
- import { askProAgentIdForManagedBrowserProfileDir, askProBrowserProfileDirForAgentId, isAskProManagedBrowserProfileDir, resolveAskProAgentId, } from "../browser/profilePaths.js";
8
+ import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, askProBrowserProfileDirForAgentId, isAskProManagedBrowserProfileDir, isLegacyAskProManagedBrowserProfileDir, resolveAskProAgentId, } from "../browser/profilePaths.js";
9
+ import { prepareAskProBrowserProfile } from "../browser/profileMigration.js";
9
10
  import { runBrowserMode } from "../browserMode.js";
10
11
  import { closeTab } from "../browser/chromeLifecycle.js";
11
12
  import { resumeBrowserSession } from "../browser/reattach.js";
@@ -28,7 +29,7 @@ async function runAskProBrowserSessionWithLease({ cwd, sessionId, temporary: req
28
29
  const temporary = requestedTemporary ?? (sessionStatus.github ? false : undefined);
29
30
  const artifactsRequested = sessionStatus.artifacts === true;
30
31
  const agentId = agentIdOverride !== undefined ? agentIdOverride : resolveAskProAgentId();
31
- const browserProfile = browserProfileDir ?? askProBrowserProfileDirForAgentId(agentId);
32
+ const browserProfile = browserProfileDir ?? (await prepareAskProBrowserProfile(agentId));
32
33
  const metadata = await readBrowserMetadata(paths.browser).catch(() => null);
33
34
  const chatgptUrl = chatgptUrlOverride ??
34
35
  (temporary === true
@@ -317,7 +318,7 @@ async function resumeAskProBrowserSessionWithLease({ cwd, sessionId, temporary,
317
318
  : effectiveTemporary === false
318
319
  ? ASK_PRO_CHATGPT_URL
319
320
  : (metadata.url ?? ASK_PRO_TEMPORARY_CHATGPT_URL);
320
- const fallbackProfile = resolveResumeBrowserProfile(metadata);
321
+ const fallbackProfile = await resolveResumeBrowserProfile(metadata);
321
322
  if (sessionStatus.status === "NEEDS_GITHUB_CONNECTION" ||
322
323
  (sessionStatus.github && metadata.runtime?.githubCheckPending === true)) {
323
324
  await closeSessionBrowserTab(cwd, sessionId, logger);
@@ -716,26 +717,39 @@ async function writeTerminalBrowserMetadata(cwd, sessionId, status, reason) {
716
717
  },
717
718
  });
718
719
  }
719
- function resolveResumeBrowserProfile(metadata) {
720
+ async function resolveResumeBrowserProfile(metadata) {
720
721
  const agentProfile = resolveStoredAgentProfile(metadata.agentId);
721
722
  const profileDir = metadata.profileDir;
722
723
  const profileAgentId = profileDir ? askProAgentIdForManagedBrowserProfileDir(profileDir) : null;
723
- if (profileAgentId && profileAgentId !== metadata.agentId) {
724
+ const legacyAgentId = profileDir ? askProAgentIdForLegacyBrowserProfileDir(profileDir) : null;
725
+ if ((profileAgentId ?? legacyAgentId) && (profileAgentId ?? legacyAgentId) !== metadata.agentId) {
724
726
  throw new Error("Stored ask-pro agent profile does not match stored agent id.");
725
727
  }
726
728
  if (profileAgentId && agentProfile) {
727
729
  return profileDir;
728
730
  }
731
+ // Sessions from earlier versions record the old profile location; resume where it is now.
732
+ if (legacyAgentId && agentProfile) {
733
+ return prepareAskProBrowserProfile(metadata.agentId);
734
+ }
729
735
  if (profileDir &&
730
736
  !profileAgentId &&
737
+ !legacyAgentId &&
731
738
  !agentProfile &&
732
739
  isAskProManagedBrowserProfileDir(profileDir)) {
733
740
  return askProBrowserProfileDirForAgentId(null);
734
741
  }
742
+ if (profileDir &&
743
+ !profileAgentId &&
744
+ !legacyAgentId &&
745
+ !agentProfile &&
746
+ isLegacyAskProManagedBrowserProfileDir(profileDir)) {
747
+ return prepareAskProBrowserProfile(null);
748
+ }
735
749
  if (profileDir) {
736
750
  throw new Error("Stored ask-pro profile path is invalid.");
737
751
  }
738
- return askProBrowserProfileDirForAgentId(metadata.agentId);
752
+ return prepareAskProBrowserProfile(metadata.agentId);
739
753
  }
740
754
  function resolveStoredAgentProfile(agentId) {
741
755
  if (!agentId)
@@ -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
+ }
@@ -6,12 +6,12 @@ export function defaultAskProBrowserProfileDir() {
6
6
  return askProBrowserProfileDirForAgentId(null);
7
7
  }
8
8
  export function askProBrowserProfileDirForAgentId(agentId, env = process.env) {
9
- return profileDirUnder(askProStateDir(env), agentId);
9
+ return askProBrowserProfileDirUnder(askProStateDir(env), agentId);
10
10
  }
11
- function profileDirUnder(root, agentId) {
11
+ export function askProBrowserProfileDirUnder(root, agentId) {
12
12
  if (!agentId)
13
13
  return path.join(root, "browser-profile");
14
- if (!RESOLVED_AGENT_ID_PATTERN.test(agentId)) {
14
+ if (!isResolvedAskProAgentId(agentId)) {
15
15
  throw new Error("Stored ask-pro agent id is invalid.");
16
16
  }
17
17
  return path.join(root, "agents", agentId, "browser-profile");
@@ -42,12 +42,36 @@ function agentIdForProfileDir(profileDir, stateRoot) {
42
42
  if (parts.length !== 2 || parts[1] !== "browser-profile")
43
43
  return null;
44
44
  const agentId = parts[0];
45
- return RESOLVED_AGENT_ID_PATTERN.test(agentId) ? agentId : null;
45
+ return isResolvedAskProAgentId(agentId) ? agentId : null;
46
46
  }
47
- function askProStateDir(env = process.env) {
47
+ export function isResolvedAskProAgentId(agentId) {
48
+ return RESOLVED_AGENT_ID_PATTERN.test(agentId);
49
+ }
50
+ export function askProStateDir(env = process.env) {
48
51
  const override = env.ASK_PRO_STATE_DIR?.trim();
49
52
  return override ? path.resolve(override) : path.join(os.homedir(), ".agents", "state", "ask-pro");
50
53
  }
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);
62
+ }
63
+ export function askProAgentIdForLegacyBrowserProfileDir(profileDir) {
64
+ for (const root of legacyAskProStateDirs()) {
65
+ const agentId = agentIdForProfileDir(profileDir, root);
66
+ if (agentId)
67
+ return agentId;
68
+ }
69
+ return null;
70
+ }
71
+ export function isLegacyAskProManagedBrowserProfileDir(profileDir) {
72
+ const resolved = normalizeProfilePath(profileDir);
73
+ return (legacyAskProStateDirs().some((root) => normalizeProfilePath(askProBrowserProfileDirUnder(root, null)) === resolved) || askProAgentIdForLegacyBrowserProfileDir(profileDir) !== null);
74
+ }
51
75
  export function resolveAskProAgentId(env = process.env) {
52
76
  const value = env.ASK_PRO_AGENT_ID;
53
77
  if (value === undefined)
@@ -168,6 +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.
173
+ export async function isBrowserProfileInUse(userDataDir, options = {}) {
174
+ let raw = null;
175
+ try {
176
+ raw = await readFile(path.join(userDataDir, ASK_PRO_PROFILE_LOCK_FILENAME), "utf8");
177
+ }
178
+ catch (error) {
179
+ if (error.code !== "ENOENT")
180
+ return true;
181
+ }
182
+ const lock = parseProfileRunLock(raw);
183
+ if (lock && lock.lockId !== options.ignoreLockId && isProcessAlive(lock.pid))
184
+ return true;
185
+ const pid = await readChromePid(userDataDir);
186
+ const port = await readDevToolsPort(userDataDir);
187
+ const [owner, devTools] = await Promise.all([
188
+ isChromeUsingUserDataDir(userDataDir),
189
+ port ? verifyDevToolsReachable({ port, attempts: 1 }) : null,
190
+ ]);
191
+ if (owner === true || (pid && isProcessAlive(pid) && devTools?.ok))
192
+ return true;
193
+ return owner === null;
194
+ }
171
195
  export async function acquireProfileRunLock(userDataDir, options) {
172
196
  const timeoutMs = options.timeoutMs;
173
197
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-pro",
3
- "version": "0.1.69",
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",