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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +6 -10
- package/dist/bin/ask-pro-cli.js +10 -3
- package/dist/src/ask-pro/browserRunner.js +20 -6
- package/dist/src/browser/profileMigration.js +299 -0
- package/dist/src/browser/profilePaths.js +29 -5
- package/dist/src/browser/profileState.js +24 -0
- package/package.json +2 -1
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
|
|
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:**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
package/dist/bin/ask-pro-cli.js
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
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
|
|
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
|
|
9
|
+
return askProBrowserProfileDirUnder(askProStateDir(env), agentId);
|
|
10
10
|
}
|
|
11
|
-
function
|
|
11
|
+
export function askProBrowserProfileDirUnder(root, agentId) {
|
|
12
12
|
if (!agentId)
|
|
13
13
|
return path.join(root, "browser-profile");
|
|
14
|
-
if (!
|
|
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
|
|
45
|
+
return isResolvedAskProAgentId(agentId) ? agentId : null;
|
|
46
46
|
}
|
|
47
|
-
function
|
|
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.
|
|
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",
|